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/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
|