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.
Files changed (85) hide show
  1. tweek/__init__.py +16 -0
  2. tweek/cli.py +3390 -0
  3. tweek/cli_helpers.py +193 -0
  4. tweek/config/__init__.py +13 -0
  5. tweek/config/allowed_dirs.yaml +23 -0
  6. tweek/config/manager.py +1064 -0
  7. tweek/config/patterns.yaml +751 -0
  8. tweek/config/tiers.yaml +129 -0
  9. tweek/diagnostics.py +589 -0
  10. tweek/hooks/__init__.py +1 -0
  11. tweek/hooks/pre_tool_use.py +861 -0
  12. tweek/integrations/__init__.py +3 -0
  13. tweek/integrations/moltbot.py +243 -0
  14. tweek/licensing.py +398 -0
  15. tweek/logging/__init__.py +9 -0
  16. tweek/logging/bundle.py +350 -0
  17. tweek/logging/json_logger.py +150 -0
  18. tweek/logging/security_log.py +745 -0
  19. tweek/mcp/__init__.py +24 -0
  20. tweek/mcp/approval.py +456 -0
  21. tweek/mcp/approval_cli.py +356 -0
  22. tweek/mcp/clients/__init__.py +37 -0
  23. tweek/mcp/clients/chatgpt.py +112 -0
  24. tweek/mcp/clients/claude_desktop.py +203 -0
  25. tweek/mcp/clients/gemini.py +178 -0
  26. tweek/mcp/proxy.py +667 -0
  27. tweek/mcp/screening.py +175 -0
  28. tweek/mcp/server.py +317 -0
  29. tweek/platform/__init__.py +131 -0
  30. tweek/plugins/__init__.py +835 -0
  31. tweek/plugins/base.py +1080 -0
  32. tweek/plugins/compliance/__init__.py +30 -0
  33. tweek/plugins/compliance/gdpr.py +333 -0
  34. tweek/plugins/compliance/gov.py +324 -0
  35. tweek/plugins/compliance/hipaa.py +285 -0
  36. tweek/plugins/compliance/legal.py +322 -0
  37. tweek/plugins/compliance/pci.py +361 -0
  38. tweek/plugins/compliance/soc2.py +275 -0
  39. tweek/plugins/detectors/__init__.py +30 -0
  40. tweek/plugins/detectors/continue_dev.py +206 -0
  41. tweek/plugins/detectors/copilot.py +254 -0
  42. tweek/plugins/detectors/cursor.py +192 -0
  43. tweek/plugins/detectors/moltbot.py +205 -0
  44. tweek/plugins/detectors/windsurf.py +214 -0
  45. tweek/plugins/git_discovery.py +395 -0
  46. tweek/plugins/git_installer.py +491 -0
  47. tweek/plugins/git_lockfile.py +338 -0
  48. tweek/plugins/git_registry.py +503 -0
  49. tweek/plugins/git_security.py +482 -0
  50. tweek/plugins/providers/__init__.py +30 -0
  51. tweek/plugins/providers/anthropic.py +181 -0
  52. tweek/plugins/providers/azure_openai.py +289 -0
  53. tweek/plugins/providers/bedrock.py +248 -0
  54. tweek/plugins/providers/google.py +197 -0
  55. tweek/plugins/providers/openai.py +230 -0
  56. tweek/plugins/scope.py +130 -0
  57. tweek/plugins/screening/__init__.py +26 -0
  58. tweek/plugins/screening/llm_reviewer.py +149 -0
  59. tweek/plugins/screening/pattern_matcher.py +273 -0
  60. tweek/plugins/screening/rate_limiter.py +174 -0
  61. tweek/plugins/screening/session_analyzer.py +159 -0
  62. tweek/proxy/__init__.py +302 -0
  63. tweek/proxy/addon.py +223 -0
  64. tweek/proxy/interceptor.py +313 -0
  65. tweek/proxy/server.py +315 -0
  66. tweek/sandbox/__init__.py +71 -0
  67. tweek/sandbox/executor.py +382 -0
  68. tweek/sandbox/linux.py +278 -0
  69. tweek/sandbox/profile_generator.py +323 -0
  70. tweek/screening/__init__.py +13 -0
  71. tweek/screening/context.py +81 -0
  72. tweek/security/__init__.py +22 -0
  73. tweek/security/llm_reviewer.py +348 -0
  74. tweek/security/rate_limiter.py +682 -0
  75. tweek/security/secret_scanner.py +506 -0
  76. tweek/security/session_analyzer.py +600 -0
  77. tweek/vault/__init__.py +40 -0
  78. tweek/vault/cross_platform.py +251 -0
  79. tweek/vault/keychain.py +288 -0
  80. tweek-0.1.0.dist-info/METADATA +335 -0
  81. tweek-0.1.0.dist-info/RECORD +85 -0
  82. tweek-0.1.0.dist-info/WHEEL +5 -0
  83. tweek-0.1.0.dist-info/entry_points.txt +25 -0
  84. tweek-0.1.0.dist-info/licenses/LICENSE +190 -0
  85. tweek-0.1.0.dist-info/top_level.txt +1 -0
@@ -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()