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
tweek/config/manager.py
ADDED
|
@@ -0,0 +1,1064 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Tweek Configuration Manager
|
|
4
|
+
|
|
5
|
+
Manages user configuration with layered defaults:
|
|
6
|
+
1. Built-in defaults (tiers.yaml)
|
|
7
|
+
2. User overrides (~/.tweek/config.yaml)
|
|
8
|
+
3. Project overrides (.tweek/config.yaml)
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
config = ConfigManager()
|
|
12
|
+
tier = config.get_tool_tier("Bash")
|
|
13
|
+
config.set_skill_tier("my-skill", "trusted")
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import difflib
|
|
17
|
+
import os
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Dict, List, Optional, Any, Tuple
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from enum import Enum
|
|
22
|
+
|
|
23
|
+
import yaml
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SecurityTier(Enum):
|
|
27
|
+
"""Security tier levels."""
|
|
28
|
+
SAFE = "safe"
|
|
29
|
+
DEFAULT = "default"
|
|
30
|
+
RISKY = "risky"
|
|
31
|
+
DANGEROUS = "dangerous"
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def from_string(cls, value: str) -> "SecurityTier":
|
|
35
|
+
"""Convert string to SecurityTier."""
|
|
36
|
+
try:
|
|
37
|
+
return cls(value.lower())
|
|
38
|
+
except ValueError:
|
|
39
|
+
return cls.DEFAULT
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class TierConfig:
|
|
44
|
+
"""Configuration for a security tier."""
|
|
45
|
+
description: str
|
|
46
|
+
screening: List[str] = field(default_factory=list)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class ToolConfig:
|
|
51
|
+
"""Configuration for a tool."""
|
|
52
|
+
name: str
|
|
53
|
+
tier: SecurityTier
|
|
54
|
+
source: str = "default" # "default", "user", "project"
|
|
55
|
+
description: Optional[str] = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class SkillConfig:
|
|
60
|
+
"""Configuration for a skill."""
|
|
61
|
+
name: str
|
|
62
|
+
tier: SecurityTier
|
|
63
|
+
source: str = "default"
|
|
64
|
+
description: Optional[str] = None
|
|
65
|
+
credentials: List[str] = field(default_factory=list)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class PluginConfig:
|
|
70
|
+
"""Configuration for a plugin."""
|
|
71
|
+
name: str
|
|
72
|
+
category: str
|
|
73
|
+
enabled: bool = True
|
|
74
|
+
settings: Dict[str, Any] = field(default_factory=dict)
|
|
75
|
+
source: str = "default"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class ConfigIssue:
|
|
80
|
+
"""A configuration validation issue."""
|
|
81
|
+
level: str # "error", "warning", "info"
|
|
82
|
+
key: str # "tools.Bahs" (the problematic key path)
|
|
83
|
+
message: str # "Unknown tool 'Bahs'"
|
|
84
|
+
suggestion: str # "Did you mean 'Bash'?"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class ConfigChange:
|
|
89
|
+
"""A single configuration change from a preset diff."""
|
|
90
|
+
key: str
|
|
91
|
+
current_value: Any
|
|
92
|
+
new_value: Any
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class ConfigManager:
|
|
96
|
+
"""Manages Tweek configuration with layered overrides."""
|
|
97
|
+
|
|
98
|
+
# Default paths
|
|
99
|
+
BUILTIN_CONFIG = Path(__file__).parent / "tiers.yaml"
|
|
100
|
+
USER_CONFIG = Path.home() / ".tweek" / "config.yaml"
|
|
101
|
+
PROJECT_CONFIG = Path(".tweek") / "config.yaml"
|
|
102
|
+
|
|
103
|
+
# Well-known tools with sensible defaults
|
|
104
|
+
KNOWN_TOOLS = {
|
|
105
|
+
"Read": ("safe", "Read files - no side effects"),
|
|
106
|
+
"Glob": ("safe", "Find files by pattern"),
|
|
107
|
+
"Grep": ("safe", "Search file contents"),
|
|
108
|
+
"Edit": ("default", "Modify existing files"),
|
|
109
|
+
"Write": ("default", "Create/overwrite files"),
|
|
110
|
+
"NotebookEdit": ("default", "Edit Jupyter notebooks"),
|
|
111
|
+
"WebFetch": ("risky", "Fetch content from URLs"),
|
|
112
|
+
"WebSearch": ("risky", "Search the web"),
|
|
113
|
+
"Bash": ("dangerous", "Execute shell commands"),
|
|
114
|
+
"Task": ("default", "Spawn subagent tasks"),
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
# Well-known skills with sensible defaults
|
|
118
|
+
KNOWN_SKILLS = {
|
|
119
|
+
"commit": ("default", "Git commit operations"),
|
|
120
|
+
"review-pr": ("safe", "Review pull requests (read-only)"),
|
|
121
|
+
"explore": ("safe", "Explore codebase (read-only)"),
|
|
122
|
+
"frontend-design": ("risky", "Generate frontend code"),
|
|
123
|
+
"dev-browser": ("risky", "Browser automation"),
|
|
124
|
+
"deploy": ("dangerous", "Deployment operations"),
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
# Configuration presets
|
|
128
|
+
PRESETS = {
|
|
129
|
+
"paranoid": {
|
|
130
|
+
"tools": {
|
|
131
|
+
"Read": "default",
|
|
132
|
+
"Glob": "default",
|
|
133
|
+
"Grep": "default",
|
|
134
|
+
"Edit": "risky",
|
|
135
|
+
"Write": "risky",
|
|
136
|
+
"WebFetch": "dangerous",
|
|
137
|
+
"WebSearch": "dangerous",
|
|
138
|
+
"Bash": "dangerous",
|
|
139
|
+
},
|
|
140
|
+
"default_tier": "risky",
|
|
141
|
+
},
|
|
142
|
+
"cautious": {
|
|
143
|
+
"tools": {
|
|
144
|
+
"Read": "safe",
|
|
145
|
+
"Glob": "safe",
|
|
146
|
+
"Grep": "safe",
|
|
147
|
+
"Edit": "default",
|
|
148
|
+
"Write": "default",
|
|
149
|
+
"WebFetch": "risky",
|
|
150
|
+
"WebSearch": "risky",
|
|
151
|
+
"Bash": "dangerous",
|
|
152
|
+
},
|
|
153
|
+
"default_tier": "default",
|
|
154
|
+
},
|
|
155
|
+
"trusted": {
|
|
156
|
+
"tools": {
|
|
157
|
+
"Read": "safe",
|
|
158
|
+
"Glob": "safe",
|
|
159
|
+
"Grep": "safe",
|
|
160
|
+
"Edit": "safe",
|
|
161
|
+
"Write": "safe",
|
|
162
|
+
"WebFetch": "default",
|
|
163
|
+
"WebSearch": "default",
|
|
164
|
+
"Bash": "risky",
|
|
165
|
+
},
|
|
166
|
+
"default_tier": "safe",
|
|
167
|
+
},
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
# Valid top-level config keys
|
|
171
|
+
VALID_TOP_LEVEL_KEYS = {
|
|
172
|
+
"tools", "skills", "default_tier", "escalations",
|
|
173
|
+
"plugins", "mcp", "proxy",
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
def __init__(
|
|
177
|
+
self,
|
|
178
|
+
user_config_path: Optional[Path] = None,
|
|
179
|
+
project_config_path: Optional[Path] = None,
|
|
180
|
+
):
|
|
181
|
+
"""Initialize the config manager."""
|
|
182
|
+
self.user_config_path = user_config_path or self.USER_CONFIG
|
|
183
|
+
self.project_config_path = project_config_path or self.PROJECT_CONFIG
|
|
184
|
+
|
|
185
|
+
# Load configurations
|
|
186
|
+
self._builtin = self._load_yaml(self.BUILTIN_CONFIG)
|
|
187
|
+
self._user = self._load_yaml(self.user_config_path)
|
|
188
|
+
self._project = self._load_yaml(self.project_config_path)
|
|
189
|
+
|
|
190
|
+
# Merged configuration cache
|
|
191
|
+
self._merged: Optional[Dict] = None
|
|
192
|
+
|
|
193
|
+
def _load_yaml(self, path: Path) -> Dict:
|
|
194
|
+
"""Load YAML file, return empty dict if not found."""
|
|
195
|
+
if path.exists():
|
|
196
|
+
try:
|
|
197
|
+
with open(path) as f:
|
|
198
|
+
return yaml.safe_load(f) or {}
|
|
199
|
+
except Exception:
|
|
200
|
+
return {}
|
|
201
|
+
return {}
|
|
202
|
+
|
|
203
|
+
def _save_yaml(self, path: Path, data: Dict) -> None:
|
|
204
|
+
"""Save configuration to YAML file."""
|
|
205
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
206
|
+
with open(path, "w") as f:
|
|
207
|
+
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
|
208
|
+
|
|
209
|
+
def _get_merged(self) -> Dict:
|
|
210
|
+
"""Get merged configuration (cached)."""
|
|
211
|
+
if self._merged is None:
|
|
212
|
+
self._merged = {
|
|
213
|
+
"tools": {},
|
|
214
|
+
"skills": {},
|
|
215
|
+
"escalations": [],
|
|
216
|
+
"default_tier": "default",
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
# Layer 1: Built-in defaults
|
|
220
|
+
if self._builtin:
|
|
221
|
+
self._merged["tools"].update(self._builtin.get("tools", {}))
|
|
222
|
+
self._merged["skills"].update(self._builtin.get("skills", {}))
|
|
223
|
+
self._merged["escalations"] = self._builtin.get("escalations", [])
|
|
224
|
+
self._merged["default_tier"] = self._builtin.get("default_tier", "default")
|
|
225
|
+
|
|
226
|
+
# Layer 2: User overrides
|
|
227
|
+
if self._user:
|
|
228
|
+
self._merged["tools"].update(self._user.get("tools", {}))
|
|
229
|
+
self._merged["skills"].update(self._user.get("skills", {}))
|
|
230
|
+
if "escalations" in self._user:
|
|
231
|
+
self._merged["escalations"].extend(self._user["escalations"])
|
|
232
|
+
if "default_tier" in self._user:
|
|
233
|
+
self._merged["default_tier"] = self._user["default_tier"]
|
|
234
|
+
|
|
235
|
+
# Layer 3: Project overrides
|
|
236
|
+
if self._project:
|
|
237
|
+
self._merged["tools"].update(self._project.get("tools", {}))
|
|
238
|
+
self._merged["skills"].update(self._project.get("skills", {}))
|
|
239
|
+
if "escalations" in self._project:
|
|
240
|
+
self._merged["escalations"].extend(self._project["escalations"])
|
|
241
|
+
if "default_tier" in self._project:
|
|
242
|
+
self._merged["default_tier"] = self._project["default_tier"]
|
|
243
|
+
|
|
244
|
+
return self._merged
|
|
245
|
+
|
|
246
|
+
def _invalidate_cache(self) -> None:
|
|
247
|
+
"""Invalidate the merged config cache."""
|
|
248
|
+
self._merged = None
|
|
249
|
+
|
|
250
|
+
# ==================== GETTERS ====================
|
|
251
|
+
|
|
252
|
+
def get_tool_tier(self, tool_name: str) -> SecurityTier:
|
|
253
|
+
"""Get the security tier for a tool."""
|
|
254
|
+
merged = self._get_merged()
|
|
255
|
+
tier_str = merged["tools"].get(tool_name, merged["default_tier"])
|
|
256
|
+
return SecurityTier.from_string(tier_str)
|
|
257
|
+
|
|
258
|
+
def get_skill_tier(self, skill_name: str) -> SecurityTier:
|
|
259
|
+
"""Get the security tier for a skill."""
|
|
260
|
+
merged = self._get_merged()
|
|
261
|
+
tier_str = merged["skills"].get(skill_name, merged["default_tier"])
|
|
262
|
+
return SecurityTier.from_string(tier_str)
|
|
263
|
+
|
|
264
|
+
def get_tool_config(self, tool_name: str) -> ToolConfig:
|
|
265
|
+
"""Get full configuration for a tool."""
|
|
266
|
+
tier = self.get_tool_tier(tool_name)
|
|
267
|
+
|
|
268
|
+
# Determine source
|
|
269
|
+
if tool_name in self._project.get("tools", {}):
|
|
270
|
+
source = "project"
|
|
271
|
+
elif tool_name in self._user.get("tools", {}):
|
|
272
|
+
source = "user"
|
|
273
|
+
else:
|
|
274
|
+
source = "default"
|
|
275
|
+
|
|
276
|
+
# Get description
|
|
277
|
+
desc = self.KNOWN_TOOLS.get(tool_name, (None, None))[1]
|
|
278
|
+
|
|
279
|
+
return ToolConfig(
|
|
280
|
+
name=tool_name,
|
|
281
|
+
tier=tier,
|
|
282
|
+
source=source,
|
|
283
|
+
description=desc,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
def get_skill_config(self, skill_name: str) -> SkillConfig:
|
|
287
|
+
"""Get full configuration for a skill."""
|
|
288
|
+
tier = self.get_skill_tier(skill_name)
|
|
289
|
+
|
|
290
|
+
# Determine source
|
|
291
|
+
if skill_name in self._project.get("skills", {}):
|
|
292
|
+
source = "project"
|
|
293
|
+
elif skill_name in self._user.get("skills", {}):
|
|
294
|
+
source = "user"
|
|
295
|
+
else:
|
|
296
|
+
source = "default"
|
|
297
|
+
|
|
298
|
+
# Get description
|
|
299
|
+
desc = self.KNOWN_SKILLS.get(skill_name, (None, None))[1]
|
|
300
|
+
|
|
301
|
+
return SkillConfig(
|
|
302
|
+
name=skill_name,
|
|
303
|
+
tier=tier,
|
|
304
|
+
source=source,
|
|
305
|
+
description=desc,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
def list_tools(self) -> List[ToolConfig]:
|
|
309
|
+
"""List all configured tools."""
|
|
310
|
+
merged = self._get_merged()
|
|
311
|
+
tools = []
|
|
312
|
+
|
|
313
|
+
# Add all known tools
|
|
314
|
+
for name in self.KNOWN_TOOLS:
|
|
315
|
+
tools.append(self.get_tool_config(name))
|
|
316
|
+
|
|
317
|
+
# Add any custom tools from config
|
|
318
|
+
for name in merged["tools"]:
|
|
319
|
+
if name not in self.KNOWN_TOOLS:
|
|
320
|
+
tools.append(self.get_tool_config(name))
|
|
321
|
+
|
|
322
|
+
return sorted(tools, key=lambda t: t.name)
|
|
323
|
+
|
|
324
|
+
def list_skills(self) -> List[SkillConfig]:
|
|
325
|
+
"""List all configured skills."""
|
|
326
|
+
merged = self._get_merged()
|
|
327
|
+
skills = []
|
|
328
|
+
|
|
329
|
+
# Add all known skills
|
|
330
|
+
for name in self.KNOWN_SKILLS:
|
|
331
|
+
skills.append(self.get_skill_config(name))
|
|
332
|
+
|
|
333
|
+
# Add any custom skills from config
|
|
334
|
+
for name in merged["skills"]:
|
|
335
|
+
if name not in self.KNOWN_SKILLS:
|
|
336
|
+
skills.append(self.get_skill_config(name))
|
|
337
|
+
|
|
338
|
+
return sorted(skills, key=lambda s: s.name)
|
|
339
|
+
|
|
340
|
+
def get_unknown_skills(self, skill_names: List[str]) -> List[str]:
|
|
341
|
+
"""Get skills that aren't in the known list or user config."""
|
|
342
|
+
merged = self._get_merged()
|
|
343
|
+
known = set(self.KNOWN_SKILLS.keys()) | set(merged["skills"].keys())
|
|
344
|
+
return [s for s in skill_names if s not in known]
|
|
345
|
+
|
|
346
|
+
def get_escalations(self) -> List[Dict]:
|
|
347
|
+
"""Get all escalation patterns."""
|
|
348
|
+
return self._get_merged()["escalations"]
|
|
349
|
+
|
|
350
|
+
def get_default_tier(self) -> SecurityTier:
|
|
351
|
+
"""Get the default tier for unknown tools/skills."""
|
|
352
|
+
return SecurityTier.from_string(self._get_merged()["default_tier"])
|
|
353
|
+
|
|
354
|
+
# ==================== SETTERS ====================
|
|
355
|
+
|
|
356
|
+
def _log_config_change(self, operation: str, **kwargs):
|
|
357
|
+
"""Log config change to security logger (never raises)."""
|
|
358
|
+
try:
|
|
359
|
+
from tweek.logging.security_log import get_logger, SecurityEvent, EventType
|
|
360
|
+
metadata = {"operation": operation}
|
|
361
|
+
metadata.update(kwargs)
|
|
362
|
+
get_logger().log(SecurityEvent(
|
|
363
|
+
event_type=EventType.CONFIG_CHANGE,
|
|
364
|
+
tool_name="config",
|
|
365
|
+
decision="allow",
|
|
366
|
+
metadata=metadata,
|
|
367
|
+
source="cli",
|
|
368
|
+
))
|
|
369
|
+
except Exception:
|
|
370
|
+
pass
|
|
371
|
+
|
|
372
|
+
def set_tool_tier(
|
|
373
|
+
self,
|
|
374
|
+
tool_name: str,
|
|
375
|
+
tier: SecurityTier,
|
|
376
|
+
scope: str = "user"
|
|
377
|
+
) -> None:
|
|
378
|
+
"""
|
|
379
|
+
Set the security tier for a tool.
|
|
380
|
+
|
|
381
|
+
Args:
|
|
382
|
+
tool_name: Name of the tool
|
|
383
|
+
tier: Security tier to set
|
|
384
|
+
scope: "user" or "project"
|
|
385
|
+
"""
|
|
386
|
+
old_tier = self.get_tool_tier(tool_name).value
|
|
387
|
+
if scope == "project":
|
|
388
|
+
if "tools" not in self._project:
|
|
389
|
+
self._project["tools"] = {}
|
|
390
|
+
self._project["tools"][tool_name] = tier.value
|
|
391
|
+
self._save_yaml(self.project_config_path, self._project)
|
|
392
|
+
else:
|
|
393
|
+
if "tools" not in self._user:
|
|
394
|
+
self._user["tools"] = {}
|
|
395
|
+
self._user["tools"][tool_name] = tier.value
|
|
396
|
+
self._save_yaml(self.user_config_path, self._user)
|
|
397
|
+
|
|
398
|
+
self._invalidate_cache()
|
|
399
|
+
self._log_config_change("set_tool_tier", tool=tool_name, old_tier=old_tier, new_tier=tier.value, scope=scope)
|
|
400
|
+
|
|
401
|
+
def set_skill_tier(
|
|
402
|
+
self,
|
|
403
|
+
skill_name: str,
|
|
404
|
+
tier: SecurityTier,
|
|
405
|
+
scope: str = "user"
|
|
406
|
+
) -> None:
|
|
407
|
+
"""
|
|
408
|
+
Set the security tier for a skill.
|
|
409
|
+
|
|
410
|
+
Args:
|
|
411
|
+
skill_name: Name of the skill
|
|
412
|
+
tier: Security tier to set
|
|
413
|
+
scope: "user" or "project"
|
|
414
|
+
"""
|
|
415
|
+
old_tier = self.get_skill_tier(skill_name).value
|
|
416
|
+
if scope == "project":
|
|
417
|
+
if "skills" not in self._project:
|
|
418
|
+
self._project["skills"] = {}
|
|
419
|
+
self._project["skills"][skill_name] = tier.value
|
|
420
|
+
self._save_yaml(self.project_config_path, self._project)
|
|
421
|
+
else:
|
|
422
|
+
if "skills" not in self._user:
|
|
423
|
+
self._user["skills"] = {}
|
|
424
|
+
self._user["skills"][skill_name] = tier.value
|
|
425
|
+
self._save_yaml(self.user_config_path, self._user)
|
|
426
|
+
|
|
427
|
+
self._invalidate_cache()
|
|
428
|
+
self._log_config_change("set_skill_tier", skill=skill_name, old_tier=old_tier, new_tier=tier.value, scope=scope)
|
|
429
|
+
|
|
430
|
+
def set_default_tier(self, tier: SecurityTier, scope: str = "user") -> None:
|
|
431
|
+
"""Set the default tier for unknown tools/skills."""
|
|
432
|
+
if scope == "project":
|
|
433
|
+
self._project["default_tier"] = tier.value
|
|
434
|
+
self._save_yaml(self.project_config_path, self._project)
|
|
435
|
+
else:
|
|
436
|
+
self._user["default_tier"] = tier.value
|
|
437
|
+
self._save_yaml(self.user_config_path, self._user)
|
|
438
|
+
|
|
439
|
+
self._invalidate_cache()
|
|
440
|
+
|
|
441
|
+
def add_escalation(
|
|
442
|
+
self,
|
|
443
|
+
pattern: str,
|
|
444
|
+
description: str,
|
|
445
|
+
escalate_to: SecurityTier,
|
|
446
|
+
scope: str = "user"
|
|
447
|
+
) -> None:
|
|
448
|
+
"""Add a custom escalation pattern."""
|
|
449
|
+
escalation = {
|
|
450
|
+
"pattern": pattern,
|
|
451
|
+
"description": description,
|
|
452
|
+
"escalate_to": escalate_to.value,
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if scope == "project":
|
|
456
|
+
if "escalations" not in self._project:
|
|
457
|
+
self._project["escalations"] = []
|
|
458
|
+
self._project["escalations"].append(escalation)
|
|
459
|
+
self._save_yaml(self.project_config_path, self._project)
|
|
460
|
+
else:
|
|
461
|
+
if "escalations" not in self._user:
|
|
462
|
+
self._user["escalations"] = []
|
|
463
|
+
self._user["escalations"].append(escalation)
|
|
464
|
+
self._save_yaml(self.user_config_path, self._user)
|
|
465
|
+
|
|
466
|
+
self._invalidate_cache()
|
|
467
|
+
|
|
468
|
+
def reset_tool(self, tool_name: str, scope: str = "user") -> bool:
|
|
469
|
+
"""Reset a tool to its default tier."""
|
|
470
|
+
if scope == "project":
|
|
471
|
+
if "tools" in self._project and tool_name in self._project["tools"]:
|
|
472
|
+
del self._project["tools"][tool_name]
|
|
473
|
+
self._save_yaml(self.project_config_path, self._project)
|
|
474
|
+
self._invalidate_cache()
|
|
475
|
+
return True
|
|
476
|
+
else:
|
|
477
|
+
if "tools" in self._user and tool_name in self._user["tools"]:
|
|
478
|
+
del self._user["tools"][tool_name]
|
|
479
|
+
self._save_yaml(self.user_config_path, self._user)
|
|
480
|
+
self._invalidate_cache()
|
|
481
|
+
return True
|
|
482
|
+
return False
|
|
483
|
+
|
|
484
|
+
def reset_skill(self, skill_name: str, scope: str = "user") -> bool:
|
|
485
|
+
"""Reset a skill to its default tier."""
|
|
486
|
+
if scope == "project":
|
|
487
|
+
if "skills" in self._project and skill_name in self._project["skills"]:
|
|
488
|
+
del self._project["skills"][skill_name]
|
|
489
|
+
self._save_yaml(self.project_config_path, self._project)
|
|
490
|
+
self._invalidate_cache()
|
|
491
|
+
return True
|
|
492
|
+
else:
|
|
493
|
+
if "skills" in self._user and skill_name in self._user["skills"]:
|
|
494
|
+
del self._user["skills"][skill_name]
|
|
495
|
+
self._save_yaml(self.user_config_path, self._user)
|
|
496
|
+
self._invalidate_cache()
|
|
497
|
+
return True
|
|
498
|
+
return False
|
|
499
|
+
|
|
500
|
+
def reset_all(self, scope: str = "user") -> None:
|
|
501
|
+
"""Reset all configuration to defaults."""
|
|
502
|
+
if scope == "project":
|
|
503
|
+
self._project = {}
|
|
504
|
+
if self.project_config_path.exists():
|
|
505
|
+
self.project_config_path.unlink()
|
|
506
|
+
else:
|
|
507
|
+
self._user = {}
|
|
508
|
+
if self.user_config_path.exists():
|
|
509
|
+
self.user_config_path.unlink()
|
|
510
|
+
|
|
511
|
+
self._invalidate_cache()
|
|
512
|
+
self._log_config_change("reset_all", scope=scope)
|
|
513
|
+
|
|
514
|
+
# ==================== BULK OPERATIONS ====================
|
|
515
|
+
|
|
516
|
+
def apply_preset(self, preset: str, scope: str = "user") -> None:
|
|
517
|
+
"""
|
|
518
|
+
Apply a configuration preset.
|
|
519
|
+
|
|
520
|
+
Presets:
|
|
521
|
+
paranoid: Maximum security, prompt for everything
|
|
522
|
+
cautious: Balanced security (recommended)
|
|
523
|
+
trusted: Minimal prompts, trust AI decisions
|
|
524
|
+
"""
|
|
525
|
+
if preset not in self.PRESETS:
|
|
526
|
+
available = ", ".join(self.PRESETS.keys())
|
|
527
|
+
raise ValueError(f"Unknown preset: {preset}. Available: {available}")
|
|
528
|
+
|
|
529
|
+
config = self.PRESETS[preset]
|
|
530
|
+
|
|
531
|
+
if scope == "project":
|
|
532
|
+
self._project.update(config)
|
|
533
|
+
self._save_yaml(self.project_config_path, self._project)
|
|
534
|
+
else:
|
|
535
|
+
self._user.update(config)
|
|
536
|
+
self._save_yaml(self.user_config_path, self._user)
|
|
537
|
+
|
|
538
|
+
self._invalidate_cache()
|
|
539
|
+
self._log_config_change("apply_preset", preset=preset, scope=scope)
|
|
540
|
+
|
|
541
|
+
def import_config(self, config_dict: Dict, scope: str = "user") -> None:
|
|
542
|
+
"""Import configuration from a dictionary."""
|
|
543
|
+
if scope == "project":
|
|
544
|
+
self._project.update(config_dict)
|
|
545
|
+
self._save_yaml(self.project_config_path, self._project)
|
|
546
|
+
else:
|
|
547
|
+
self._user.update(config_dict)
|
|
548
|
+
self._save_yaml(self.user_config_path, self._user)
|
|
549
|
+
|
|
550
|
+
self._invalidate_cache()
|
|
551
|
+
|
|
552
|
+
def export_config(self, scope: str = "user") -> Dict:
|
|
553
|
+
"""Export configuration as a dictionary."""
|
|
554
|
+
if scope == "project":
|
|
555
|
+
return dict(self._project)
|
|
556
|
+
elif scope == "user":
|
|
557
|
+
return dict(self._user)
|
|
558
|
+
else:
|
|
559
|
+
return dict(self._get_merged())
|
|
560
|
+
|
|
561
|
+
# ==================== VALIDATION ====================
|
|
562
|
+
|
|
563
|
+
def validate_config(self, scope: str = "merged") -> List[ConfigIssue]:
|
|
564
|
+
"""
|
|
565
|
+
Validate configuration for errors, typos, and warnings.
|
|
566
|
+
|
|
567
|
+
Checks:
|
|
568
|
+
- Unknown top-level keys (with typo suggestions)
|
|
569
|
+
- Unknown tool names (with typo suggestions)
|
|
570
|
+
- Invalid tier values
|
|
571
|
+
- Invalid plugin references
|
|
572
|
+
|
|
573
|
+
Args:
|
|
574
|
+
scope: "user", "project", or "merged"
|
|
575
|
+
|
|
576
|
+
Returns:
|
|
577
|
+
List of ConfigIssue objects.
|
|
578
|
+
"""
|
|
579
|
+
issues: List[ConfigIssue] = []
|
|
580
|
+
|
|
581
|
+
if scope == "user":
|
|
582
|
+
configs_to_check = [("user", self._user)]
|
|
583
|
+
elif scope == "project":
|
|
584
|
+
configs_to_check = [("project", self._project)]
|
|
585
|
+
else:
|
|
586
|
+
configs_to_check = [("user", self._user), ("project", self._project)]
|
|
587
|
+
|
|
588
|
+
valid_tiers = {t.value for t in SecurityTier}
|
|
589
|
+
known_tool_names = set(self.KNOWN_TOOLS.keys())
|
|
590
|
+
known_skill_names = set(self.KNOWN_SKILLS.keys())
|
|
591
|
+
|
|
592
|
+
for source_name, config in configs_to_check:
|
|
593
|
+
if not config:
|
|
594
|
+
continue
|
|
595
|
+
|
|
596
|
+
# Check top-level keys
|
|
597
|
+
for key in config:
|
|
598
|
+
if key not in self.VALID_TOP_LEVEL_KEYS:
|
|
599
|
+
matches = difflib.get_close_matches(
|
|
600
|
+
key, self.VALID_TOP_LEVEL_KEYS, n=1, cutoff=0.6
|
|
601
|
+
)
|
|
602
|
+
suggestion = f"Did you mean '{matches[0]}'?" if matches else ""
|
|
603
|
+
issues.append(ConfigIssue(
|
|
604
|
+
level="error",
|
|
605
|
+
key=f"{source_name}.{key}",
|
|
606
|
+
message=f"Unknown config key '{key}'",
|
|
607
|
+
suggestion=suggestion,
|
|
608
|
+
))
|
|
609
|
+
|
|
610
|
+
# Check tool names and tiers
|
|
611
|
+
tools = config.get("tools", {})
|
|
612
|
+
if isinstance(tools, dict):
|
|
613
|
+
for tool_name, tier_value in tools.items():
|
|
614
|
+
# Check if tool name is known
|
|
615
|
+
if tool_name not in known_tool_names:
|
|
616
|
+
# Check merged config tools too (custom tools are fine)
|
|
617
|
+
merged_tools = self._get_merged().get("tools", {})
|
|
618
|
+
if tool_name not in merged_tools:
|
|
619
|
+
matches = difflib.get_close_matches(
|
|
620
|
+
tool_name, known_tool_names, n=1, cutoff=0.6
|
|
621
|
+
)
|
|
622
|
+
suggestion = f"Did you mean '{matches[0]}'?" if matches else ""
|
|
623
|
+
issues.append(ConfigIssue(
|
|
624
|
+
level="warning",
|
|
625
|
+
key=f"tools.{tool_name}",
|
|
626
|
+
message=f"Unknown tool '{tool_name}'",
|
|
627
|
+
suggestion=suggestion,
|
|
628
|
+
))
|
|
629
|
+
|
|
630
|
+
# Check tier value
|
|
631
|
+
if isinstance(tier_value, str) and tier_value not in valid_tiers:
|
|
632
|
+
matches = difflib.get_close_matches(
|
|
633
|
+
tier_value, valid_tiers, n=1, cutoff=0.5
|
|
634
|
+
)
|
|
635
|
+
suggestion = f"Did you mean '{matches[0]}'?" if matches else f"Valid tiers: {', '.join(sorted(valid_tiers))}"
|
|
636
|
+
issues.append(ConfigIssue(
|
|
637
|
+
level="error",
|
|
638
|
+
key=f"tools.{tool_name}",
|
|
639
|
+
message=f"Invalid tier '{tier_value}' for tool '{tool_name}'",
|
|
640
|
+
suggestion=suggestion,
|
|
641
|
+
))
|
|
642
|
+
|
|
643
|
+
# Check skill names and tiers
|
|
644
|
+
skills = config.get("skills", {})
|
|
645
|
+
if isinstance(skills, dict):
|
|
646
|
+
for skill_name, tier_value in skills.items():
|
|
647
|
+
if skill_name not in known_skill_names:
|
|
648
|
+
matches = difflib.get_close_matches(
|
|
649
|
+
skill_name, known_skill_names, n=1, cutoff=0.6
|
|
650
|
+
)
|
|
651
|
+
if matches:
|
|
652
|
+
issues.append(ConfigIssue(
|
|
653
|
+
level="warning",
|
|
654
|
+
key=f"skills.{skill_name}",
|
|
655
|
+
message=f"Unknown skill '{skill_name}'",
|
|
656
|
+
suggestion=f"Did you mean '{matches[0]}'?",
|
|
657
|
+
))
|
|
658
|
+
|
|
659
|
+
if isinstance(tier_value, str) and tier_value not in valid_tiers:
|
|
660
|
+
matches = difflib.get_close_matches(
|
|
661
|
+
tier_value, valid_tiers, n=1, cutoff=0.5
|
|
662
|
+
)
|
|
663
|
+
suggestion = f"Did you mean '{matches[0]}'?" if matches else f"Valid tiers: {', '.join(sorted(valid_tiers))}"
|
|
664
|
+
issues.append(ConfigIssue(
|
|
665
|
+
level="error",
|
|
666
|
+
key=f"skills.{skill_name}",
|
|
667
|
+
message=f"Invalid tier '{tier_value}' for skill '{skill_name}'",
|
|
668
|
+
suggestion=suggestion,
|
|
669
|
+
))
|
|
670
|
+
|
|
671
|
+
# Check default_tier
|
|
672
|
+
default_tier = config.get("default_tier")
|
|
673
|
+
if default_tier and default_tier not in valid_tiers:
|
|
674
|
+
matches = difflib.get_close_matches(
|
|
675
|
+
default_tier, valid_tiers, n=1, cutoff=0.5
|
|
676
|
+
)
|
|
677
|
+
suggestion = f"Did you mean '{matches[0]}'?" if matches else f"Valid tiers: {', '.join(sorted(valid_tiers))}"
|
|
678
|
+
issues.append(ConfigIssue(
|
|
679
|
+
level="error",
|
|
680
|
+
key="default_tier",
|
|
681
|
+
message=f"Invalid default tier '{default_tier}'",
|
|
682
|
+
suggestion=suggestion,
|
|
683
|
+
))
|
|
684
|
+
|
|
685
|
+
return issues
|
|
686
|
+
|
|
687
|
+
def diff_preset(self, preset_name: str) -> List[ConfigChange]:
|
|
688
|
+
"""
|
|
689
|
+
Show what would change if a preset were applied.
|
|
690
|
+
|
|
691
|
+
Args:
|
|
692
|
+
preset_name: Name of the preset ("paranoid", "cautious", "trusted").
|
|
693
|
+
|
|
694
|
+
Returns:
|
|
695
|
+
List of ConfigChange showing current vs. new values.
|
|
696
|
+
|
|
697
|
+
Raises:
|
|
698
|
+
ValueError: If preset_name is unknown.
|
|
699
|
+
"""
|
|
700
|
+
if preset_name not in self.PRESETS:
|
|
701
|
+
available = ", ".join(self.PRESETS.keys())
|
|
702
|
+
raise ValueError(f"Unknown preset: {preset_name}. Available: {available}")
|
|
703
|
+
|
|
704
|
+
preset = self.PRESETS[preset_name]
|
|
705
|
+
changes: List[ConfigChange] = []
|
|
706
|
+
|
|
707
|
+
# Check default_tier change
|
|
708
|
+
current_default = self._get_merged().get("default_tier", "default")
|
|
709
|
+
new_default = preset.get("default_tier", current_default)
|
|
710
|
+
if current_default != new_default:
|
|
711
|
+
changes.append(ConfigChange(
|
|
712
|
+
key="default_tier",
|
|
713
|
+
current_value=current_default,
|
|
714
|
+
new_value=new_default,
|
|
715
|
+
))
|
|
716
|
+
|
|
717
|
+
# Check tool tier changes
|
|
718
|
+
preset_tools = preset.get("tools", {})
|
|
719
|
+
for tool_name, new_tier in preset_tools.items():
|
|
720
|
+
current_tier = self.get_tool_tier(tool_name).value
|
|
721
|
+
if current_tier != new_tier:
|
|
722
|
+
changes.append(ConfigChange(
|
|
723
|
+
key=f"tools.{tool_name}",
|
|
724
|
+
current_value=current_tier,
|
|
725
|
+
new_value=new_tier,
|
|
726
|
+
))
|
|
727
|
+
|
|
728
|
+
return changes
|
|
729
|
+
|
|
730
|
+
# ==================== PLUGIN CONFIGURATION ====================
|
|
731
|
+
|
|
732
|
+
def get_plugin_config(self, category: str, plugin_name: str) -> PluginConfig:
|
|
733
|
+
"""
|
|
734
|
+
Get configuration for a plugin.
|
|
735
|
+
|
|
736
|
+
Args:
|
|
737
|
+
category: Plugin category (compliance, providers, detectors, screening)
|
|
738
|
+
plugin_name: Plugin name
|
|
739
|
+
|
|
740
|
+
Returns:
|
|
741
|
+
PluginConfig with merged settings
|
|
742
|
+
"""
|
|
743
|
+
merged = self._get_merged()
|
|
744
|
+
plugins = merged.get("plugins", {})
|
|
745
|
+
cat_config = plugins.get(category, {})
|
|
746
|
+
modules = cat_config.get("modules", cat_config)
|
|
747
|
+
|
|
748
|
+
plugin_settings = modules.get(plugin_name, {})
|
|
749
|
+
if isinstance(plugin_settings, dict):
|
|
750
|
+
enabled = plugin_settings.get("enabled", True)
|
|
751
|
+
settings = {k: v for k, v in plugin_settings.items() if k != "enabled"}
|
|
752
|
+
else:
|
|
753
|
+
enabled = bool(plugin_settings)
|
|
754
|
+
settings = {}
|
|
755
|
+
|
|
756
|
+
# Determine source
|
|
757
|
+
source = "default"
|
|
758
|
+
if "plugins" in self._project:
|
|
759
|
+
proj_cat = self._project["plugins"].get(category, {})
|
|
760
|
+
proj_modules = proj_cat.get("modules", proj_cat)
|
|
761
|
+
if plugin_name in proj_modules:
|
|
762
|
+
source = "project"
|
|
763
|
+
if source == "default" and "plugins" in self._user:
|
|
764
|
+
user_cat = self._user["plugins"].get(category, {})
|
|
765
|
+
user_modules = user_cat.get("modules", user_cat)
|
|
766
|
+
if plugin_name in user_modules:
|
|
767
|
+
source = "user"
|
|
768
|
+
|
|
769
|
+
return PluginConfig(
|
|
770
|
+
name=plugin_name,
|
|
771
|
+
category=category,
|
|
772
|
+
enabled=enabled,
|
|
773
|
+
settings=settings,
|
|
774
|
+
source=source,
|
|
775
|
+
)
|
|
776
|
+
|
|
777
|
+
def set_plugin_enabled(
|
|
778
|
+
self,
|
|
779
|
+
category: str,
|
|
780
|
+
plugin_name: str,
|
|
781
|
+
enabled: bool,
|
|
782
|
+
scope: str = "user"
|
|
783
|
+
) -> None:
|
|
784
|
+
"""
|
|
785
|
+
Enable or disable a plugin.
|
|
786
|
+
|
|
787
|
+
Args:
|
|
788
|
+
category: Plugin category
|
|
789
|
+
plugin_name: Plugin name
|
|
790
|
+
enabled: Whether to enable the plugin
|
|
791
|
+
scope: Config scope (user or project)
|
|
792
|
+
"""
|
|
793
|
+
target = self._project if scope == "project" else self._user
|
|
794
|
+
|
|
795
|
+
if "plugins" not in target:
|
|
796
|
+
target["plugins"] = {}
|
|
797
|
+
if category not in target["plugins"]:
|
|
798
|
+
target["plugins"][category] = {"modules": {}}
|
|
799
|
+
if "modules" not in target["plugins"][category]:
|
|
800
|
+
target["plugins"][category]["modules"] = {}
|
|
801
|
+
|
|
802
|
+
if plugin_name not in target["plugins"][category]["modules"]:
|
|
803
|
+
target["plugins"][category]["modules"][plugin_name] = {}
|
|
804
|
+
|
|
805
|
+
target["plugins"][category]["modules"][plugin_name]["enabled"] = enabled
|
|
806
|
+
|
|
807
|
+
path = self.project_config_path if scope == "project" else self.user_config_path
|
|
808
|
+
self._save_yaml(path, target)
|
|
809
|
+
self._invalidate_cache()
|
|
810
|
+
|
|
811
|
+
def set_plugin_setting(
|
|
812
|
+
self,
|
|
813
|
+
category: str,
|
|
814
|
+
plugin_name: str,
|
|
815
|
+
key: str,
|
|
816
|
+
value: Any,
|
|
817
|
+
scope: str = "user"
|
|
818
|
+
) -> None:
|
|
819
|
+
"""
|
|
820
|
+
Set a plugin setting.
|
|
821
|
+
|
|
822
|
+
Args:
|
|
823
|
+
category: Plugin category
|
|
824
|
+
plugin_name: Plugin name
|
|
825
|
+
key: Setting key
|
|
826
|
+
value: Setting value
|
|
827
|
+
scope: Config scope
|
|
828
|
+
"""
|
|
829
|
+
target = self._project if scope == "project" else self._user
|
|
830
|
+
|
|
831
|
+
if "plugins" not in target:
|
|
832
|
+
target["plugins"] = {}
|
|
833
|
+
if category not in target["plugins"]:
|
|
834
|
+
target["plugins"][category] = {"modules": {}}
|
|
835
|
+
if "modules" not in target["plugins"][category]:
|
|
836
|
+
target["plugins"][category]["modules"] = {}
|
|
837
|
+
if plugin_name not in target["plugins"][category]["modules"]:
|
|
838
|
+
target["plugins"][category]["modules"][plugin_name] = {}
|
|
839
|
+
|
|
840
|
+
target["plugins"][category]["modules"][plugin_name][key] = value
|
|
841
|
+
|
|
842
|
+
path = self.project_config_path if scope == "project" else self.user_config_path
|
|
843
|
+
self._save_yaml(path, target)
|
|
844
|
+
self._invalidate_cache()
|
|
845
|
+
|
|
846
|
+
def list_plugin_configs(self, category: Optional[str] = None) -> List[PluginConfig]:
|
|
847
|
+
"""
|
|
848
|
+
List all plugin configurations.
|
|
849
|
+
|
|
850
|
+
Args:
|
|
851
|
+
category: Optional category filter
|
|
852
|
+
|
|
853
|
+
Returns:
|
|
854
|
+
List of PluginConfig objects
|
|
855
|
+
"""
|
|
856
|
+
merged = self._get_merged()
|
|
857
|
+
plugins = merged.get("plugins", {})
|
|
858
|
+
configs = []
|
|
859
|
+
|
|
860
|
+
categories = [category] if category else list(plugins.keys())
|
|
861
|
+
|
|
862
|
+
for cat in categories:
|
|
863
|
+
cat_config = plugins.get(cat, {})
|
|
864
|
+
modules = cat_config.get("modules", cat_config)
|
|
865
|
+
|
|
866
|
+
if isinstance(modules, dict):
|
|
867
|
+
for name in modules:
|
|
868
|
+
configs.append(self.get_plugin_config(cat, name))
|
|
869
|
+
|
|
870
|
+
return configs
|
|
871
|
+
|
|
872
|
+
def get_plugins_dict(self) -> Dict[str, Any]:
|
|
873
|
+
"""
|
|
874
|
+
Get the full plugins configuration dictionary.
|
|
875
|
+
|
|
876
|
+
Returns:
|
|
877
|
+
Dictionary with all plugin configurations
|
|
878
|
+
"""
|
|
879
|
+
merged = self._get_merged()
|
|
880
|
+
return merged.get("plugins", {})
|
|
881
|
+
|
|
882
|
+
def reset_plugin(
|
|
883
|
+
self,
|
|
884
|
+
category: str,
|
|
885
|
+
plugin_name: str,
|
|
886
|
+
scope: str = "user"
|
|
887
|
+
) -> bool:
|
|
888
|
+
"""
|
|
889
|
+
Reset a plugin to default configuration.
|
|
890
|
+
|
|
891
|
+
Args:
|
|
892
|
+
category: Plugin category
|
|
893
|
+
plugin_name: Plugin name
|
|
894
|
+
scope: Config scope
|
|
895
|
+
|
|
896
|
+
Returns:
|
|
897
|
+
True if reset was performed
|
|
898
|
+
"""
|
|
899
|
+
target = self._project if scope == "project" else self._user
|
|
900
|
+
|
|
901
|
+
if "plugins" not in target:
|
|
902
|
+
return False
|
|
903
|
+
if category not in target["plugins"]:
|
|
904
|
+
return False
|
|
905
|
+
|
|
906
|
+
modules = target["plugins"][category].get("modules", target["plugins"][category])
|
|
907
|
+
if plugin_name in modules:
|
|
908
|
+
del modules[plugin_name]
|
|
909
|
+
|
|
910
|
+
path = self.project_config_path if scope == "project" else self.user_config_path
|
|
911
|
+
self._save_yaml(path, target)
|
|
912
|
+
self._invalidate_cache()
|
|
913
|
+
return True
|
|
914
|
+
|
|
915
|
+
return False
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
# ==================== PLUGIN SCOPING ====================
|
|
919
|
+
|
|
920
|
+
def get_plugin_scope(self, plugin_name: str) -> Optional[Dict[str, Any]]:
|
|
921
|
+
"""
|
|
922
|
+
Get the scope configuration for a plugin.
|
|
923
|
+
|
|
924
|
+
Searches across all plugin categories in user config.
|
|
925
|
+
|
|
926
|
+
Args:
|
|
927
|
+
plugin_name: Plugin name
|
|
928
|
+
|
|
929
|
+
Returns:
|
|
930
|
+
Scope dict if configured, None if no scope (global plugin)
|
|
931
|
+
"""
|
|
932
|
+
for source in [self._project, self._user]:
|
|
933
|
+
plugins = source.get("plugins", {})
|
|
934
|
+
for cat_name, cat_config in plugins.items():
|
|
935
|
+
modules = cat_config.get("modules", cat_config)
|
|
936
|
+
if isinstance(modules, dict) and plugin_name in modules:
|
|
937
|
+
plugin_cfg = modules[plugin_name]
|
|
938
|
+
if isinstance(plugin_cfg, dict) and "scope" in plugin_cfg:
|
|
939
|
+
return plugin_cfg["scope"]
|
|
940
|
+
return None
|
|
941
|
+
|
|
942
|
+
def set_plugin_scope(
|
|
943
|
+
self,
|
|
944
|
+
plugin_name: str,
|
|
945
|
+
scope: Optional[Dict[str, Any]],
|
|
946
|
+
scope_level: str = "user"
|
|
947
|
+
) -> None:
|
|
948
|
+
"""
|
|
949
|
+
Set or clear the scope for a plugin.
|
|
950
|
+
|
|
951
|
+
Finds the plugin across categories and sets its scope.
|
|
952
|
+
|
|
953
|
+
Args:
|
|
954
|
+
plugin_name: Plugin name
|
|
955
|
+
scope: Scope dict (None to clear/make global)
|
|
956
|
+
scope_level: "user" or "project"
|
|
957
|
+
"""
|
|
958
|
+
target = self._project if scope_level == "project" else self._user
|
|
959
|
+
|
|
960
|
+
# Find which category this plugin is in
|
|
961
|
+
category = self._find_plugin_category(plugin_name)
|
|
962
|
+
|
|
963
|
+
if "plugins" not in target:
|
|
964
|
+
target["plugins"] = {}
|
|
965
|
+
if category not in target["plugins"]:
|
|
966
|
+
target["plugins"][category] = {"modules": {}}
|
|
967
|
+
if "modules" not in target["plugins"][category]:
|
|
968
|
+
target["plugins"][category]["modules"] = {}
|
|
969
|
+
if plugin_name not in target["plugins"][category]["modules"]:
|
|
970
|
+
target["plugins"][category]["modules"][plugin_name] = {}
|
|
971
|
+
|
|
972
|
+
plugin_cfg = target["plugins"][category]["modules"][plugin_name]
|
|
973
|
+
|
|
974
|
+
if scope is None:
|
|
975
|
+
# Clear scope
|
|
976
|
+
plugin_cfg.pop("scope", None)
|
|
977
|
+
else:
|
|
978
|
+
plugin_cfg["scope"] = scope
|
|
979
|
+
|
|
980
|
+
path = self.project_config_path if scope_level == "project" else self.user_config_path
|
|
981
|
+
self._save_yaml(path, target)
|
|
982
|
+
self._invalidate_cache()
|
|
983
|
+
|
|
984
|
+
def _find_plugin_category(self, plugin_name: str) -> str:
|
|
985
|
+
"""
|
|
986
|
+
Find which category a plugin belongs to.
|
|
987
|
+
|
|
988
|
+
Searches built-in configs and registry.
|
|
989
|
+
"""
|
|
990
|
+
# Check known compliance plugins
|
|
991
|
+
compliance_plugins = {"gov", "hipaa", "pci", "legal", "soc2", "gdpr"}
|
|
992
|
+
if plugin_name in compliance_plugins:
|
|
993
|
+
return "compliance"
|
|
994
|
+
|
|
995
|
+
# Check known screening plugins
|
|
996
|
+
screening_plugins = {"rate_limiter", "pattern_matcher", "llm_reviewer", "session_analyzer"}
|
|
997
|
+
if plugin_name in screening_plugins:
|
|
998
|
+
return "screening"
|
|
999
|
+
|
|
1000
|
+
# Check known provider plugins
|
|
1001
|
+
provider_plugins = {"anthropic", "openai", "google", "bedrock", "azure_openai"}
|
|
1002
|
+
if plugin_name in provider_plugins:
|
|
1003
|
+
return "providers"
|
|
1004
|
+
|
|
1005
|
+
# Check known detector plugins
|
|
1006
|
+
detector_plugins = {"moltbot", "cursor", "continue", "copilot", "windsurf"}
|
|
1007
|
+
if plugin_name in detector_plugins:
|
|
1008
|
+
return "detectors"
|
|
1009
|
+
|
|
1010
|
+
# Search existing config
|
|
1011
|
+
for source in [self._project, self._user, self._builtin]:
|
|
1012
|
+
plugins = source.get("plugins", {})
|
|
1013
|
+
for cat_name, cat_config in plugins.items():
|
|
1014
|
+
modules = cat_config.get("modules", cat_config)
|
|
1015
|
+
if isinstance(modules, dict) and plugin_name in modules:
|
|
1016
|
+
return cat_name
|
|
1017
|
+
|
|
1018
|
+
# Default to screening
|
|
1019
|
+
return "screening"
|
|
1020
|
+
|
|
1021
|
+
# ==================== FULL CONFIG ====================
|
|
1022
|
+
|
|
1023
|
+
def get_full_config(self) -> Dict[str, Any]:
|
|
1024
|
+
"""
|
|
1025
|
+
Get the complete merged configuration as a dictionary.
|
|
1026
|
+
|
|
1027
|
+
Returns all configuration including tools, skills, plugins,
|
|
1028
|
+
escalations, and any MCP/proxy settings.
|
|
1029
|
+
"""
|
|
1030
|
+
merged = dict(self._get_merged())
|
|
1031
|
+
|
|
1032
|
+
# Include plugin configs
|
|
1033
|
+
for source in [self._builtin, self._user, self._project]:
|
|
1034
|
+
if "plugins" in source:
|
|
1035
|
+
if "plugins" not in merged:
|
|
1036
|
+
merged["plugins"] = {}
|
|
1037
|
+
for cat, cat_cfg in source["plugins"].items():
|
|
1038
|
+
if cat not in merged["plugins"]:
|
|
1039
|
+
merged["plugins"][cat] = {}
|
|
1040
|
+
if isinstance(cat_cfg, dict):
|
|
1041
|
+
for k, v in cat_cfg.items():
|
|
1042
|
+
if k == "modules" and isinstance(v, dict):
|
|
1043
|
+
if "modules" not in merged["plugins"][cat]:
|
|
1044
|
+
merged["plugins"][cat]["modules"] = {}
|
|
1045
|
+
merged["plugins"][cat]["modules"].update(v)
|
|
1046
|
+
else:
|
|
1047
|
+
merged["plugins"][cat][k] = v
|
|
1048
|
+
|
|
1049
|
+
# Include MCP config
|
|
1050
|
+
for source in [self._builtin, self._user, self._project]:
|
|
1051
|
+
if "mcp" in source:
|
|
1052
|
+
merged["mcp"] = source["mcp"]
|
|
1053
|
+
|
|
1054
|
+
# Include proxy config
|
|
1055
|
+
for source in [self._builtin, self._user, self._project]:
|
|
1056
|
+
if "proxy" in source:
|
|
1057
|
+
merged["proxy"] = source["proxy"]
|
|
1058
|
+
|
|
1059
|
+
return merged
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
def get_config() -> ConfigManager:
|
|
1063
|
+
"""Get the global configuration manager."""
|
|
1064
|
+
return ConfigManager()
|