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,356 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Tweek MCP Approval CLI Daemon
4
+
5
+ Interactive terminal interface for reviewing and deciding on
6
+ MCP proxy approval requests. Polls the approval queue and
7
+ displays pending requests for human review.
8
+
9
+ Usage:
10
+ tweek mcp approve # Start daemon (2s poll)
11
+ tweek mcp approve --poll-interval 5 # Slower polling
12
+ tweek mcp pending # One-shot listing
13
+ tweek mcp decide <ID> approve # One-shot decision
14
+ """
15
+
16
+ import logging
17
+ import sys
18
+ import time
19
+ from typing import Optional
20
+
21
+ from tweek.mcp.approval import ApprovalQueue, ApprovalRequest, ApprovalStatus
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ def display_pending(queue: ApprovalQueue) -> int:
27
+ """
28
+ Display all pending approval requests as a rich table.
29
+
30
+ Returns the number of pending requests displayed.
31
+ """
32
+ try:
33
+ from rich.console import Console
34
+ from rich.table import Table
35
+ except ImportError:
36
+ return _display_pending_plain(queue)
37
+
38
+ console = Console()
39
+ pending = queue.get_pending()
40
+
41
+ if not pending:
42
+ console.print("[dim]No pending approval requests.[/dim]")
43
+ return 0
44
+
45
+ table = Table(title="Pending Approval Requests", show_lines=True)
46
+ table.add_column("ID", style="cyan", width=10)
47
+ table.add_column("Server", style="blue")
48
+ table.add_column("Tool", style="yellow")
49
+ table.add_column("Risk", width=10)
50
+ table.add_column("Reason")
51
+ table.add_column("Time Left", width=10)
52
+
53
+ for req in pending:
54
+ risk_style = _risk_style(req.risk_level)
55
+ remaining = req.time_remaining
56
+ time_str = f"{int(remaining)}s" if remaining > 0 else "[red]EXPIRED[/red]"
57
+
58
+ table.add_row(
59
+ req.short_id,
60
+ req.upstream_server,
61
+ req.tool_name,
62
+ f"[{risk_style}]{req.risk_level}[/{risk_style}]",
63
+ req.screening_reason[:60],
64
+ time_str,
65
+ )
66
+
67
+ console.print(table)
68
+ return len(pending)
69
+
70
+
71
+ def display_request_detail(request: ApprovalRequest):
72
+ """Display detailed information about a single request."""
73
+ try:
74
+ from rich.console import Console
75
+ from rich.panel import Panel
76
+ from rich.text import Text
77
+ except ImportError:
78
+ _display_request_plain(request)
79
+ return
80
+
81
+ console = Console()
82
+ risk_style = _risk_style(request.risk_level)
83
+
84
+ lines = [
85
+ f"[bold]Request:[/bold] {request.short_id}",
86
+ f"[bold]Server:[/bold] {request.upstream_server}",
87
+ f"[bold]Tool:[/bold] {request.tool_name}",
88
+ f"[bold]Risk:[/bold] [{risk_style}]{request.risk_level}[/{risk_style}]",
89
+ f"[bold]Reason:[/bold] {request.screening_reason}",
90
+ "",
91
+ "[bold]Arguments:[/bold]",
92
+ ]
93
+
94
+ # Display arguments (already redacted from queue storage)
95
+ args = request.arguments
96
+ for key, value in args.items():
97
+ display_val = str(value)
98
+ if len(display_val) > 120:
99
+ display_val = display_val[:120] + "..."
100
+ lines.append(f" {key}: {display_val}")
101
+
102
+ # Display findings
103
+ findings = request.screening_findings
104
+ if findings:
105
+ lines.append("")
106
+ lines.append("[bold]Findings:[/bold]")
107
+ for f in findings[:5]:
108
+ name = f.get("name", f.get("pattern", "unknown"))
109
+ severity = f.get("severity", "")
110
+ lines.append(f" - {name} ({severity})")
111
+
112
+ remaining = request.time_remaining
113
+ if remaining > 0:
114
+ lines.append("")
115
+ lines.append(f"[dim]Auto-deny in {int(remaining)}s[/dim]")
116
+
117
+ panel = Panel(
118
+ "\n".join(lines),
119
+ title=f"Approval Request {request.short_id}",
120
+ border_style=risk_style,
121
+ )
122
+ console.print(panel)
123
+
124
+
125
+ def run_approval_daemon(
126
+ queue: ApprovalQueue,
127
+ poll_interval: float = 2.0,
128
+ ):
129
+ """
130
+ Run the interactive approval daemon.
131
+
132
+ Polls for pending requests, displays them, and prompts for decisions.
133
+ Runs until Ctrl+C.
134
+ """
135
+ try:
136
+ from rich.console import Console
137
+ except ImportError:
138
+ print("Error: rich library required for approval daemon", file=sys.stderr)
139
+ return
140
+
141
+ console = Console()
142
+ console.print("[bold]Tweek MCP Approval Daemon[/bold]")
143
+ console.print(f"Polling every {poll_interval}s. Press Ctrl+C to exit.\n")
144
+
145
+ seen_ids = set()
146
+
147
+ try:
148
+ while True:
149
+ pending = queue.get_pending()
150
+
151
+ # Show new requests
152
+ new_requests = [r for r in pending if r.id not in seen_ids]
153
+
154
+ for req in new_requests:
155
+ seen_ids.add(req.id)
156
+ console.print()
157
+ display_request_detail(req)
158
+ console.print()
159
+
160
+ # Prompt for decision
161
+ decision = _prompt_decision(console, req)
162
+ if decision == "quit":
163
+ console.print("[dim]Exiting approval daemon.[/dim]")
164
+ return
165
+ elif decision == "skip":
166
+ continue
167
+ elif decision == "approve":
168
+ success = queue.decide(
169
+ req.id, ApprovalStatus.APPROVED, decided_by="cli"
170
+ )
171
+ if success:
172
+ console.print(f"[green]Approved {req.short_id}[/green]")
173
+ _log_decision(req, "approved")
174
+ else:
175
+ console.print(
176
+ f"[yellow]Could not approve {req.short_id} "
177
+ f"(may have expired)[/yellow]"
178
+ )
179
+ elif decision == "deny":
180
+ notes = _prompt_notes(console)
181
+ success = queue.decide(
182
+ req.id,
183
+ ApprovalStatus.DENIED,
184
+ decided_by="cli",
185
+ notes=notes,
186
+ )
187
+ if success:
188
+ console.print(f"[red]Denied {req.short_id}[/red]")
189
+ _log_decision(req, "denied")
190
+ else:
191
+ console.print(
192
+ f"[yellow]Could not deny {req.short_id} "
193
+ f"(may have expired)[/yellow]"
194
+ )
195
+
196
+ if not new_requests and pending:
197
+ # Still have pending but already seen them
198
+ pass
199
+
200
+ time.sleep(poll_interval)
201
+
202
+ except KeyboardInterrupt:
203
+ console.print("\n[dim]Approval daemon stopped.[/dim]")
204
+
205
+ # Print summary
206
+ stats = queue.get_stats()
207
+ console.print("\n[bold]Session Summary:[/bold]")
208
+ for status, count in stats.items():
209
+ console.print(f" {status}: {count}")
210
+
211
+
212
+ def decide_request(
213
+ queue: ApprovalQueue,
214
+ request_id: str,
215
+ decision: str,
216
+ notes: Optional[str] = None,
217
+ ) -> bool:
218
+ """
219
+ Make a one-shot decision on a specific request.
220
+
221
+ Args:
222
+ queue: The approval queue
223
+ request_id: Full or short ID
224
+ decision: "approve" or "deny"
225
+ notes: Optional decision notes
226
+
227
+ Returns:
228
+ True if decision was recorded successfully
229
+ """
230
+ request = queue.get_request(request_id)
231
+ if request is None:
232
+ print(f"Request '{request_id}' not found.", file=sys.stderr)
233
+ return False
234
+
235
+ if request.status != ApprovalStatus.PENDING:
236
+ print(
237
+ f"Request '{request_id}' is not pending (status: {request.status.value}).",
238
+ file=sys.stderr,
239
+ )
240
+ return False
241
+
242
+ status = ApprovalStatus.APPROVED if decision == "approve" else ApprovalStatus.DENIED
243
+ success = queue.decide(request.id, status, decided_by="cli", notes=notes)
244
+
245
+ if success:
246
+ _log_decision(request, decision)
247
+
248
+ return success
249
+
250
+
251
+ def _prompt_decision(console, request: ApprovalRequest) -> str:
252
+ """Prompt user for a decision. Returns 'approve', 'deny', 'skip', or 'quit'."""
253
+ while True:
254
+ try:
255
+ console.print(
256
+ "[bold][A][/bold]pprove "
257
+ "[bold][D][/bold]eny "
258
+ "[bold][S][/bold]kip "
259
+ "[bold][Q][/bold]uit"
260
+ )
261
+ choice = input(f"Decision for {request.short_id}: ").strip().lower()
262
+ except EOFError:
263
+ return "quit"
264
+
265
+ if choice in ("a", "approve"):
266
+ return "approve"
267
+ elif choice in ("d", "deny"):
268
+ return "deny"
269
+ elif choice in ("s", "skip"):
270
+ return "skip"
271
+ elif choice in ("q", "quit"):
272
+ return "quit"
273
+ else:
274
+ console.print("[yellow]Invalid choice. Use A/D/S/Q.[/yellow]")
275
+
276
+
277
+ def _prompt_notes(console) -> Optional[str]:
278
+ """Prompt for optional denial notes."""
279
+ try:
280
+ notes = input("Notes (optional, press Enter to skip): ").strip()
281
+ return notes if notes else None
282
+ except EOFError:
283
+ return None
284
+
285
+
286
+ def _risk_style(risk_level: str) -> str:
287
+ """Return a rich style string for a risk level."""
288
+ styles = {
289
+ "safe": "green",
290
+ "default": "blue",
291
+ "risky": "yellow",
292
+ "dangerous": "red bold",
293
+ }
294
+ return styles.get(risk_level, "white")
295
+
296
+
297
+ def _log_decision(request: ApprovalRequest, decision: str):
298
+ """Log an approval decision to SecurityLogger."""
299
+ try:
300
+ from tweek.logging.security_log import (
301
+ SecurityLogger,
302
+ SecurityEvent,
303
+ EventType,
304
+ get_logger,
305
+ )
306
+
307
+ evt = EventType.USER_APPROVED if decision == "approved" else EventType.USER_DENIED
308
+
309
+ sec_logger = get_logger()
310
+ sec_logger.log(SecurityEvent(
311
+ event_type=evt,
312
+ tool_name=request.tool_name,
313
+ decision=decision,
314
+ decision_reason=request.screening_reason,
315
+ user_response=decision,
316
+ metadata={
317
+ "source": "mcp_proxy_approval_cli",
318
+ "upstream_server": request.upstream_server,
319
+ "request_id": request.id,
320
+ "risk_level": request.risk_level,
321
+ },
322
+ ))
323
+ except Exception as e:
324
+ logger.debug(f"Failed to log approval decision: {e}")
325
+
326
+
327
+ def _display_pending_plain(queue: ApprovalQueue) -> int:
328
+ """Fallback plain-text display when rich is unavailable."""
329
+ pending = queue.get_pending()
330
+ if not pending:
331
+ print("No pending approval requests.")
332
+ return 0
333
+
334
+ print(f"\nPending Approval Requests ({len(pending)}):")
335
+ print("-" * 70)
336
+ for req in pending:
337
+ remaining = req.time_remaining
338
+ time_str = f"{int(remaining)}s" if remaining > 0 else "EXPIRED"
339
+ print(
340
+ f" [{req.short_id}] {req.upstream_server}/{req.tool_name} "
341
+ f"({req.risk_level}) - {time_str} remaining"
342
+ )
343
+ print(f" Reason: {req.screening_reason[:60]}")
344
+ print()
345
+ return len(pending)
346
+
347
+
348
+ def _display_request_plain(request: ApprovalRequest):
349
+ """Fallback plain-text display for a single request."""
350
+ print(f"\nRequest: {request.short_id}")
351
+ print(f" Server: {request.upstream_server}")
352
+ print(f" Tool: {request.tool_name}")
353
+ print(f" Risk: {request.risk_level}")
354
+ print(f" Reason: {request.screening_reason}")
355
+ remaining = request.time_remaining
356
+ print(f" Time: {int(remaining)}s remaining")
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ MCP Client Auto-Configuration
4
+
5
+ Handles installing and uninstalling Tweek as an MCP server
6
+ for various desktop LLM clients.
7
+ """
8
+
9
+ from tweek.mcp.clients.claude_desktop import ClaudeDesktopClient
10
+ from tweek.mcp.clients.chatgpt import ChatGPTClient
11
+ from tweek.mcp.clients.gemini import GeminiClient
12
+
13
+ SUPPORTED_CLIENTS = {
14
+ "claude-desktop": ClaudeDesktopClient,
15
+ "chatgpt": ChatGPTClient,
16
+ "gemini": GeminiClient,
17
+ }
18
+
19
+
20
+ def get_client(name: str):
21
+ """Get a client configuration handler by name."""
22
+ client_class = SUPPORTED_CLIENTS.get(name)
23
+ if client_class is None:
24
+ raise ValueError(
25
+ f"Unknown client: {name}. "
26
+ f"Supported: {', '.join(SUPPORTED_CLIENTS.keys())}"
27
+ )
28
+ return client_class()
29
+
30
+
31
+ __all__ = [
32
+ "ClaudeDesktopClient",
33
+ "ChatGPTClient",
34
+ "GeminiClient",
35
+ "SUPPORTED_CLIENTS",
36
+ "get_client",
37
+ ]
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ ChatGPT Desktop MCP Client Configuration
4
+
5
+ ChatGPT Desktop supports MCP via Developer Mode.
6
+ Unlike Claude Desktop, ChatGPT requires manual Developer Mode activation,
7
+ so we provide guided instructions plus the config commands.
8
+ """
9
+
10
+ import json
11
+ import shutil
12
+ import sys
13
+ from pathlib import Path
14
+ from typing import Dict, Any
15
+
16
+
17
+ class ChatGPTClient:
18
+ """Manage Tweek MCP integration with ChatGPT Desktop."""
19
+
20
+ CLIENT_NAME = "chatgpt"
21
+ DISPLAY_NAME = "ChatGPT Desktop"
22
+
23
+ def _get_tweek_command(self) -> str:
24
+ """Get the path to the tweek executable."""
25
+ tweek_path = shutil.which("tweek")
26
+ if tweek_path:
27
+ return tweek_path
28
+ return sys.executable
29
+
30
+ def _get_tweek_args(self) -> list:
31
+ """Get the arguments for the tweek MCP server."""
32
+ tweek_path = shutil.which("tweek")
33
+ if tweek_path:
34
+ return ["mcp", "serve"]
35
+ return ["-m", "tweek.mcp", "serve"]
36
+
37
+ def install(self) -> Dict[str, Any]:
38
+ """
39
+ Provide instructions for ChatGPT Desktop MCP setup.
40
+
41
+ ChatGPT requires manual Developer Mode activation.
42
+ """
43
+ command = self._get_tweek_command()
44
+ args = self._get_tweek_args()
45
+
46
+ instructions = [
47
+ "ChatGPT Desktop MCP Setup:",
48
+ "",
49
+ "1. Open ChatGPT Desktop",
50
+ "2. Go to Settings -> Developer -> Advanced",
51
+ "3. Enable Developer Mode",
52
+ "4. Go to Connectors settings",
53
+ "5. Click 'Add MCP Server'",
54
+ "6. Configure with:",
55
+ f" - Name: Tweek Security",
56
+ f" - Command: {command}",
57
+ f" - Args: {' '.join(args)}",
58
+ "",
59
+ "After adding, restart ChatGPT Desktop to activate.",
60
+ ]
61
+
62
+ return {
63
+ "success": True,
64
+ "client": self.DISPLAY_NAME,
65
+ "manual_setup_required": True,
66
+ "command": command,
67
+ "args": args,
68
+ "instructions": instructions,
69
+ "message": "ChatGPT requires manual Developer Mode activation. See instructions.",
70
+ }
71
+
72
+ def uninstall(self) -> Dict[str, Any]:
73
+ """Provide instructions for removing Tweek from ChatGPT Desktop."""
74
+ return {
75
+ "success": True,
76
+ "client": self.DISPLAY_NAME,
77
+ "manual_removal_required": True,
78
+ "instructions": [
79
+ "To remove Tweek from ChatGPT Desktop:",
80
+ "",
81
+ "1. Open ChatGPT Desktop",
82
+ "2. Go to Settings -> Developer -> Connectors",
83
+ "3. Find 'Tweek Security' and remove it",
84
+ "4. Restart ChatGPT Desktop",
85
+ ],
86
+ "message": "Follow instructions to remove Tweek from ChatGPT Desktop.",
87
+ }
88
+
89
+ def status(self) -> Dict[str, Any]:
90
+ """
91
+ Check ChatGPT Desktop MCP status.
92
+
93
+ Since ChatGPT doesn't use a file-based config, we can only
94
+ check if the app is installed.
95
+ """
96
+ # Check if ChatGPT Desktop is installed
97
+ chatgpt_installed = False
98
+
99
+ if sys.platform == "darwin":
100
+ chatgpt_path = Path("/Applications/ChatGPT.app")
101
+ chatgpt_installed = chatgpt_path.exists()
102
+ elif sys.platform == "win32":
103
+ # Windows store app or standard install
104
+ chatgpt_installed = shutil.which("chatgpt") is not None
105
+
106
+ return {
107
+ "installed": None, # Can't determine MCP status
108
+ "client": self.DISPLAY_NAME,
109
+ "app_installed": chatgpt_installed,
110
+ "note": "Cannot detect MCP configuration status for ChatGPT Desktop. "
111
+ "Check Developer Mode -> Connectors manually.",
112
+ }