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
tweek/mcp/__init__.py ADDED
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Tweek MCP Security Gateway & Proxy
4
+
5
+ MCP (Model Context Protocol) integration for desktop LLM applications:
6
+ - Claude Desktop
7
+ - ChatGPT Desktop
8
+ - Gemini CLI
9
+ - VS Code (Continue.dev)
10
+
11
+ Two modes of operation:
12
+ - **Proxy** (recommended): Transparently wraps upstream MCP servers with
13
+ security screening and human-in-the-loop approval. Tools keep their
14
+ original names. Use: tweek mcp proxy
15
+ - **Gateway**: Exposes tweek_vault and tweek_status as new MCP tools for
16
+ capabilities not available as built-in desktop client tools.
17
+ Use: tweek mcp serve
18
+
19
+ Built-in desktop client tools (Bash, Read, Write, etc.) cannot be
20
+ intercepted via MCP — use CLI hooks for Claude Code, or the HTTP
21
+ proxy for Cursor/direct API calls.
22
+ """
23
+
24
+ __all__ = ["create_server", "create_proxy"]
tweek/mcp/approval.py ADDED
@@ -0,0 +1,456 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Tweek MCP Approval Queue
4
+
5
+ SQLite-backed queue for human-in-the-loop approval of MCP proxy requests.
6
+ When the screening pipeline flags a tool call as needing confirmation,
7
+ the request is queued here. A separate approval daemon (CLI or web)
8
+ reads pending requests and records approve/deny decisions.
9
+
10
+ Database location: ~/.tweek/approvals.db
11
+ """
12
+
13
+ import json
14
+ import logging
15
+ import sqlite3
16
+ import time
17
+ import uuid
18
+ from contextlib import contextmanager
19
+ from dataclasses import dataclass
20
+ from datetime import datetime
21
+ from enum import Enum
22
+ from pathlib import Path
23
+ from typing import Any, Dict, List, Optional
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ class ApprovalStatus(Enum):
29
+ """Status of an approval request."""
30
+ PENDING = "pending"
31
+ APPROVED = "approved"
32
+ DENIED = "denied"
33
+ EXPIRED = "expired"
34
+
35
+
36
+ @dataclass
37
+ class ApprovalRequest:
38
+ """A pending or decided approval request."""
39
+ id: str
40
+ timestamp: str
41
+ upstream_server: str
42
+ tool_name: str
43
+ arguments_json: str
44
+ screening_reason: str
45
+ screening_findings_json: str
46
+ risk_level: str
47
+ status: ApprovalStatus
48
+ decided_at: Optional[str]
49
+ decided_by: Optional[str]
50
+ decision_notes: Optional[str]
51
+ timeout_seconds: int
52
+
53
+ @property
54
+ def arguments(self) -> Dict[str, Any]:
55
+ """Parse arguments from JSON."""
56
+ try:
57
+ return json.loads(self.arguments_json)
58
+ except (json.JSONDecodeError, TypeError):
59
+ return {}
60
+
61
+ @property
62
+ def screening_findings(self) -> List[Dict]:
63
+ """Parse findings from JSON."""
64
+ try:
65
+ return json.loads(self.screening_findings_json)
66
+ except (json.JSONDecodeError, TypeError):
67
+ return []
68
+
69
+ @property
70
+ def is_expired(self) -> bool:
71
+ """Check if request has exceeded its timeout."""
72
+ if self.status != ApprovalStatus.PENDING:
73
+ return False
74
+ try:
75
+ ts = datetime.fromisoformat(self.timestamp)
76
+ elapsed = (datetime.utcnow() - ts).total_seconds()
77
+ return elapsed >= self.timeout_seconds
78
+ except (ValueError, TypeError):
79
+ return False
80
+
81
+ @property
82
+ def time_remaining(self) -> float:
83
+ """Seconds remaining before timeout. Returns 0 if expired."""
84
+ try:
85
+ ts = datetime.fromisoformat(self.timestamp)
86
+ elapsed = (datetime.utcnow() - ts).total_seconds()
87
+ remaining = self.timeout_seconds - elapsed
88
+ return max(0.0, remaining)
89
+ except (ValueError, TypeError):
90
+ return 0.0
91
+
92
+ @property
93
+ def short_id(self) -> str:
94
+ """First 8 characters of the ID for display."""
95
+ return self.id[:8]
96
+
97
+
98
+ class ApprovalQueue:
99
+ """
100
+ SQLite-backed approval queue for MCP proxy requests.
101
+
102
+ Stores pending approval requests and their decisions.
103
+ Designed for concurrent access from the proxy (writer)
104
+ and approval daemon (reader/writer).
105
+ """
106
+
107
+ DEFAULT_DB_PATH = Path.home() / ".tweek" / "approvals.db"
108
+ DEFAULT_TIMEOUT = 300 # 5 minutes
109
+ MAX_RETRIES = 3
110
+ RETRY_BASE_MS = 100
111
+
112
+ def __init__(
113
+ self,
114
+ db_path: Optional[Path] = None,
115
+ default_timeout: int = DEFAULT_TIMEOUT,
116
+ ):
117
+ self.db_path = db_path or self.DEFAULT_DB_PATH
118
+ self.default_timeout = default_timeout
119
+ self._ensure_db_exists()
120
+
121
+ def _ensure_db_exists(self):
122
+ """Create database and tables if they don't exist."""
123
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
124
+
125
+ with self._get_connection() as conn:
126
+ conn.executescript("""
127
+ PRAGMA journal_mode=WAL;
128
+
129
+ CREATE TABLE IF NOT EXISTS approval_requests (
130
+ id TEXT PRIMARY KEY,
131
+ timestamp TEXT NOT NULL DEFAULT (datetime('now')),
132
+ upstream_server TEXT NOT NULL,
133
+ tool_name TEXT NOT NULL,
134
+ arguments_json TEXT NOT NULL,
135
+ screening_reason TEXT,
136
+ screening_findings_json TEXT,
137
+ risk_level TEXT,
138
+ status TEXT NOT NULL DEFAULT 'pending',
139
+ decided_at TEXT,
140
+ decided_by TEXT,
141
+ decision_notes TEXT,
142
+ timeout_seconds INTEGER NOT NULL DEFAULT 300
143
+ );
144
+
145
+ CREATE INDEX IF NOT EXISTS idx_approval_status
146
+ ON approval_requests(status);
147
+ CREATE INDEX IF NOT EXISTS idx_approval_timestamp
148
+ ON approval_requests(timestamp);
149
+ """)
150
+
151
+ @contextmanager
152
+ def _get_connection(self):
153
+ """Get a database connection with WAL mode and proper cleanup."""
154
+ conn = sqlite3.connect(str(self.db_path), timeout=5)
155
+ conn.row_factory = sqlite3.Row
156
+ try:
157
+ yield conn
158
+ conn.commit()
159
+ finally:
160
+ conn.close()
161
+
162
+ def _retry_on_lock(self, func, *args, **kwargs):
163
+ """Retry a function on SQLite OperationalError (lock contention)."""
164
+ for attempt in range(self.MAX_RETRIES):
165
+ try:
166
+ return func(*args, **kwargs)
167
+ except sqlite3.OperationalError as e:
168
+ if "locked" in str(e) and attempt < self.MAX_RETRIES - 1:
169
+ delay_ms = self.RETRY_BASE_MS * (2 ** attempt)
170
+ time.sleep(delay_ms / 1000.0)
171
+ continue
172
+ raise
173
+ return None
174
+
175
+ def enqueue(
176
+ self,
177
+ upstream_server: str,
178
+ tool_name: str,
179
+ arguments: Dict[str, Any],
180
+ screening_reason: str,
181
+ screening_findings: List[Dict],
182
+ risk_level: str = "unknown",
183
+ timeout_seconds: Optional[int] = None,
184
+ ) -> str:
185
+ """
186
+ Add a new approval request to the queue.
187
+
188
+ Args:
189
+ upstream_server: Name of the upstream MCP server
190
+ tool_name: Original tool name (without namespace prefix)
191
+ arguments: Tool call arguments (will be redacted before storage)
192
+ screening_reason: Why screening flagged this call
193
+ screening_findings: Detailed findings from screening
194
+ risk_level: Risk level from screening (safe/default/risky/dangerous)
195
+ timeout_seconds: Custom timeout, or use default
196
+
197
+ Returns:
198
+ UUID string for the new request
199
+ """
200
+ request_id = str(uuid.uuid4())
201
+ timeout = timeout_seconds or self.default_timeout
202
+
203
+ # Redact arguments before storage
204
+ redacted_args = self._redact_arguments(arguments)
205
+
206
+ def _do_enqueue():
207
+ with self._get_connection() as conn:
208
+ conn.execute(
209
+ """
210
+ INSERT INTO approval_requests (
211
+ id, upstream_server, tool_name, arguments_json,
212
+ screening_reason, screening_findings_json,
213
+ risk_level, status, timeout_seconds
214
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?)
215
+ """,
216
+ (
217
+ request_id,
218
+ upstream_server,
219
+ tool_name,
220
+ json.dumps(redacted_args),
221
+ screening_reason,
222
+ json.dumps(screening_findings),
223
+ risk_level,
224
+ timeout,
225
+ ),
226
+ )
227
+
228
+ # Log the enqueue event
229
+ try:
230
+ from tweek.logging.security_log import get_logger, SecurityEvent, EventType
231
+ get_logger().log(SecurityEvent(
232
+ event_type=EventType.MCP_APPROVAL,
233
+ tool_name="approval_queue",
234
+ decision="allow",
235
+ metadata={
236
+ "upstream_server": upstream_server,
237
+ "tool_name": tool_name,
238
+ "screening_reason": screening_reason,
239
+ "risk_level": risk_level,
240
+ },
241
+ source="mcp",
242
+ ))
243
+ except Exception:
244
+ pass
245
+
246
+ return request_id
247
+
248
+ return self._retry_on_lock(_do_enqueue)
249
+
250
+ def get_pending(self) -> List[ApprovalRequest]:
251
+ """Get all pending approval requests, ordered by timestamp."""
252
+ with self._get_connection() as conn:
253
+ rows = conn.execute(
254
+ """
255
+ SELECT * FROM approval_requests
256
+ WHERE status = 'pending'
257
+ ORDER BY timestamp ASC
258
+ """,
259
+ ).fetchall()
260
+
261
+ return [self._row_to_request(row) for row in rows]
262
+
263
+ def get_request(self, request_id: str) -> Optional[ApprovalRequest]:
264
+ """Get a specific approval request by ID (supports short IDs)."""
265
+ with self._get_connection() as conn:
266
+ # Try exact match first
267
+ row = conn.execute(
268
+ "SELECT * FROM approval_requests WHERE id = ?",
269
+ (request_id,),
270
+ ).fetchone()
271
+
272
+ # If not found, try prefix match (short ID)
273
+ if row is None and len(request_id) < 36:
274
+ row = conn.execute(
275
+ "SELECT * FROM approval_requests WHERE id LIKE ?",
276
+ (f"{request_id}%",),
277
+ ).fetchone()
278
+
279
+ return self._row_to_request(row) if row else None
280
+
281
+ def decide(
282
+ self,
283
+ request_id: str,
284
+ status: ApprovalStatus,
285
+ decided_by: str = "cli",
286
+ notes: Optional[str] = None,
287
+ ) -> bool:
288
+ """
289
+ Record a decision for an approval request.
290
+
291
+ Args:
292
+ request_id: UUID or short ID of the request
293
+ status: APPROVED or DENIED
294
+ decided_by: Who made the decision ("cli", "web", "timeout")
295
+ notes: Optional notes about the decision
296
+
297
+ Returns:
298
+ True if updated, False if not found or not pending
299
+ """
300
+ if status not in (ApprovalStatus.APPROVED, ApprovalStatus.DENIED, ApprovalStatus.EXPIRED):
301
+ raise ValueError(f"Invalid decision status: {status}")
302
+
303
+ # Resolve short IDs
304
+ request = self.get_request(request_id)
305
+ if request is None:
306
+ return False
307
+ if request.status != ApprovalStatus.PENDING:
308
+ return False
309
+
310
+ full_id = request.id
311
+
312
+ def _do_decide():
313
+ with self._get_connection() as conn:
314
+ cursor = conn.execute(
315
+ """
316
+ UPDATE approval_requests
317
+ SET status = ?, decided_at = datetime('now'),
318
+ decided_by = ?, decision_notes = ?
319
+ WHERE id = ? AND status = 'pending'
320
+ """,
321
+ (status.value, decided_by, notes, full_id),
322
+ )
323
+ updated = cursor.rowcount > 0
324
+
325
+ if updated:
326
+ try:
327
+ from tweek.logging.security_log import get_logger, SecurityEvent, EventType
328
+ get_logger().log(SecurityEvent(
329
+ event_type=EventType.MCP_APPROVAL,
330
+ tool_name="approval_queue",
331
+ decision="allow" if status == ApprovalStatus.APPROVED else "block",
332
+ metadata={
333
+ "request_id": full_id,
334
+ "status": status.value,
335
+ "decided_by": decided_by,
336
+ },
337
+ source="mcp",
338
+ ))
339
+ except Exception:
340
+ pass
341
+
342
+ return updated
343
+
344
+ return self._retry_on_lock(_do_decide)
345
+
346
+ def get_decision(self, request_id: str) -> Optional[ApprovalStatus]:
347
+ """Get the current status for a request."""
348
+ request = self.get_request(request_id)
349
+ return request.status if request else None
350
+
351
+ def expire_stale(self) -> int:
352
+ """
353
+ Expire all pending requests that have exceeded their timeout.
354
+
355
+ Returns:
356
+ Number of requests expired
357
+ """
358
+ with self._get_connection() as conn:
359
+ cursor = conn.execute(
360
+ """
361
+ UPDATE approval_requests
362
+ SET status = 'expired',
363
+ decided_at = datetime('now'),
364
+ decided_by = 'timeout',
365
+ decision_notes = 'Auto-denied: approval timeout exceeded'
366
+ WHERE status = 'pending'
367
+ AND (
368
+ julianday('now') - julianday(timestamp)
369
+ ) * 86400.0 >= timeout_seconds
370
+ """,
371
+ )
372
+ count = cursor.rowcount
373
+
374
+ if count > 0:
375
+ logger.info(f"Expired {count} stale approval request(s)")
376
+
377
+ try:
378
+ from tweek.logging.security_log import get_logger, SecurityEvent, EventType
379
+ get_logger().log(SecurityEvent(
380
+ event_type=EventType.MCP_APPROVAL,
381
+ tool_name="approval_queue",
382
+ decision="block",
383
+ metadata={
384
+ "expired_count": count,
385
+ },
386
+ source="mcp",
387
+ ))
388
+ except Exception:
389
+ pass
390
+
391
+ return count
392
+
393
+ def cleanup(self, days: int = 7) -> int:
394
+ """
395
+ Delete old decided/expired requests.
396
+
397
+ Args:
398
+ days: Delete requests older than this many days
399
+
400
+ Returns:
401
+ Number of requests deleted
402
+ """
403
+ with self._get_connection() as conn:
404
+ cursor = conn.execute(
405
+ """
406
+ DELETE FROM approval_requests
407
+ WHERE status != 'pending'
408
+ AND julianday('now') - julianday(timestamp) > ?
409
+ """,
410
+ (days,),
411
+ )
412
+ return cursor.rowcount
413
+
414
+ def count_pending(self) -> int:
415
+ """Return the number of pending requests."""
416
+ with self._get_connection() as conn:
417
+ row = conn.execute(
418
+ "SELECT COUNT(*) as cnt FROM approval_requests WHERE status = 'pending'"
419
+ ).fetchone()
420
+ return row["cnt"] if row else 0
421
+
422
+ def get_stats(self) -> Dict[str, int]:
423
+ """Return counts by status."""
424
+ with self._get_connection() as conn:
425
+ rows = conn.execute(
426
+ "SELECT status, COUNT(*) as cnt FROM approval_requests GROUP BY status"
427
+ ).fetchall()
428
+ return {row["status"]: row["cnt"] for row in rows}
429
+
430
+ def _row_to_request(self, row: sqlite3.Row) -> ApprovalRequest:
431
+ """Convert a database row to an ApprovalRequest."""
432
+ return ApprovalRequest(
433
+ id=row["id"],
434
+ timestamp=row["timestamp"],
435
+ upstream_server=row["upstream_server"],
436
+ tool_name=row["tool_name"],
437
+ arguments_json=row["arguments_json"],
438
+ screening_reason=row["screening_reason"] or "",
439
+ screening_findings_json=row["screening_findings_json"] or "[]",
440
+ risk_level=row["risk_level"] or "unknown",
441
+ status=ApprovalStatus(row["status"]),
442
+ decided_at=row["decided_at"],
443
+ decided_by=row["decided_by"],
444
+ decision_notes=row["decision_notes"],
445
+ timeout_seconds=row["timeout_seconds"],
446
+ )
447
+
448
+ def _redact_arguments(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
449
+ """Redact sensitive data from tool arguments before storage."""
450
+ try:
451
+ from tweek.logging.security_log import LogRedactor
452
+ redactor = LogRedactor(enabled=True)
453
+ return redactor.redact_dict(arguments)
454
+ except ImportError:
455
+ # If logging module unavailable, store as-is
456
+ return arguments