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
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Tweek Session Analyzer Screening Plugin
|
|
4
|
+
|
|
5
|
+
Cross-turn anomaly detection for conversation hijacking:
|
|
6
|
+
- Privilege escalation patterns
|
|
7
|
+
- Repeated denial attacks
|
|
8
|
+
- Behavior shift detection
|
|
9
|
+
- Instruction persistence
|
|
10
|
+
- ACIP graduated escalation
|
|
11
|
+
|
|
12
|
+
Free and open source.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from typing import Optional, Dict, Any, List
|
|
16
|
+
from tweek.plugins.base import (
|
|
17
|
+
ScreeningPlugin,
|
|
18
|
+
ScreeningResult,
|
|
19
|
+
Finding,
|
|
20
|
+
Severity,
|
|
21
|
+
ActionType,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SessionAnalyzerPlugin(ScreeningPlugin):
|
|
26
|
+
"""
|
|
27
|
+
Session analyzer screening plugin.
|
|
28
|
+
|
|
29
|
+
Analyzes session history to detect cross-turn anomalies
|
|
30
|
+
that would be missed by single-command analysis.
|
|
31
|
+
|
|
32
|
+
Free and open source.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
VERSION = "1.0.0"
|
|
36
|
+
DESCRIPTION = "Cross-turn anomaly detection for session analysis"
|
|
37
|
+
AUTHOR = "Tweek"
|
|
38
|
+
REQUIRES_LICENSE = "free"
|
|
39
|
+
TAGS = ["screening", "session-analysis", "anomaly-detection"]
|
|
40
|
+
|
|
41
|
+
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
|
42
|
+
super().__init__(config)
|
|
43
|
+
self._analyzer = None
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def name(self) -> str:
|
|
47
|
+
return "session_analyzer"
|
|
48
|
+
|
|
49
|
+
def _get_analyzer(self):
|
|
50
|
+
"""Lazy initialization of session analyzer."""
|
|
51
|
+
if self._analyzer is None:
|
|
52
|
+
try:
|
|
53
|
+
from tweek.security.session_analyzer import SessionAnalyzer
|
|
54
|
+
|
|
55
|
+
self._analyzer = SessionAnalyzer(
|
|
56
|
+
lookback_minutes=self._config.get("lookback_minutes", 30),
|
|
57
|
+
)
|
|
58
|
+
except ImportError:
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
return self._analyzer
|
|
62
|
+
|
|
63
|
+
def screen(
|
|
64
|
+
self,
|
|
65
|
+
tool_name: str,
|
|
66
|
+
content: str,
|
|
67
|
+
context: Dict[str, Any]
|
|
68
|
+
) -> ScreeningResult:
|
|
69
|
+
"""
|
|
70
|
+
Analyze session for cross-turn anomalies.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
tool_name: Name of the tool being invoked
|
|
74
|
+
content: Command or content (used for context)
|
|
75
|
+
context: Must include 'session_id'
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
ScreeningResult with session analysis
|
|
79
|
+
"""
|
|
80
|
+
analyzer = self._get_analyzer()
|
|
81
|
+
if analyzer is None:
|
|
82
|
+
return ScreeningResult(
|
|
83
|
+
allowed=True,
|
|
84
|
+
plugin_name=self.name,
|
|
85
|
+
reason="Session analyzer not available",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
session_id = context.get("session_id")
|
|
89
|
+
if not session_id:
|
|
90
|
+
return ScreeningResult(
|
|
91
|
+
allowed=True,
|
|
92
|
+
plugin_name=self.name,
|
|
93
|
+
reason="No session ID for analysis",
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
result = analyzer.analyze(session_id)
|
|
97
|
+
|
|
98
|
+
if not result.is_suspicious:
|
|
99
|
+
return ScreeningResult(
|
|
100
|
+
allowed=True,
|
|
101
|
+
plugin_name=self.name,
|
|
102
|
+
risk_level="safe",
|
|
103
|
+
details=result.details,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# Convert anomalies to findings
|
|
107
|
+
from tweek.security.session_analyzer import AnomalyType
|
|
108
|
+
|
|
109
|
+
anomaly_severity = {
|
|
110
|
+
AnomalyType.PRIVILEGE_ESCALATION: Severity.HIGH,
|
|
111
|
+
AnomalyType.PATH_ESCALATION: Severity.HIGH,
|
|
112
|
+
AnomalyType.REPEATED_DENIALS: Severity.MEDIUM,
|
|
113
|
+
AnomalyType.BEHAVIOR_SHIFT: Severity.MEDIUM,
|
|
114
|
+
AnomalyType.SUSPICIOUS_PATTERN: Severity.HIGH,
|
|
115
|
+
AnomalyType.VELOCITY_CHANGE: Severity.LOW,
|
|
116
|
+
AnomalyType.TIER_DRIFT: Severity.MEDIUM,
|
|
117
|
+
AnomalyType.CAPABILITY_AGGREGATION: Severity.HIGH,
|
|
118
|
+
AnomalyType.GRADUATED_ESCALATION: Severity.HIGH,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
findings = []
|
|
122
|
+
for anomaly in result.anomalies:
|
|
123
|
+
findings.append(Finding(
|
|
124
|
+
pattern_name=f"session_{anomaly.value}",
|
|
125
|
+
matched_text=f"Session: {session_id[:20]}...",
|
|
126
|
+
severity=anomaly_severity.get(anomaly, Severity.MEDIUM),
|
|
127
|
+
description=anomaly.value.replace("_", " ").title(),
|
|
128
|
+
recommended_action=ActionType.ASK,
|
|
129
|
+
metadata={"anomaly_type": anomaly.value}
|
|
130
|
+
))
|
|
131
|
+
|
|
132
|
+
risk_level = "dangerous" if result.is_high_risk else "suspicious"
|
|
133
|
+
|
|
134
|
+
return ScreeningResult(
|
|
135
|
+
allowed=not result.is_high_risk,
|
|
136
|
+
plugin_name=self.name,
|
|
137
|
+
reason=f"Session anomalies detected: {', '.join(a.value for a in result.anomalies)}",
|
|
138
|
+
risk_level=risk_level,
|
|
139
|
+
confidence=result.risk_score,
|
|
140
|
+
should_prompt=result.is_suspicious,
|
|
141
|
+
findings=findings,
|
|
142
|
+
details={
|
|
143
|
+
"risk_score": result.risk_score,
|
|
144
|
+
"anomalies": [a.value for a in result.anomalies],
|
|
145
|
+
"recommendations": result.recommendations,
|
|
146
|
+
**result.details,
|
|
147
|
+
}
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
def get_session_profile(self, session_id: str) -> Optional[Dict[str, Any]]:
|
|
151
|
+
"""Get the stored profile for a session."""
|
|
152
|
+
analyzer = self._get_analyzer()
|
|
153
|
+
if analyzer is None:
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
# Would need to add this method to SessionAnalyzer
|
|
157
|
+
# For now, just return the analysis
|
|
158
|
+
result = analyzer.analyze(session_id)
|
|
159
|
+
return result.details if result else None
|
tweek/proxy/__init__.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tweek Proxy - Optional LLM API security proxy.
|
|
3
|
+
|
|
4
|
+
This module provides transparent HTTPS interception for LLM API calls,
|
|
5
|
+
enabling security screening for any application (not just Claude Code).
|
|
6
|
+
|
|
7
|
+
Installation:
|
|
8
|
+
pip install tweek[proxy]
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
tweek proxy start # Start the proxy server
|
|
12
|
+
tweek proxy stop # Stop the proxy server
|
|
13
|
+
tweek proxy trust # Install CA certificate
|
|
14
|
+
tweek proxy status # Show proxy status
|
|
15
|
+
|
|
16
|
+
The proxy is DISABLED by default. Enable with:
|
|
17
|
+
tweek proxy enable
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import shutil
|
|
21
|
+
import socket
|
|
22
|
+
from typing import Optional, Tuple
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
|
|
25
|
+
# Check if proxy dependencies are available
|
|
26
|
+
PROXY_AVAILABLE = False
|
|
27
|
+
PROXY_MISSING_DEPS: list[str] = []
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
import mitmproxy
|
|
31
|
+
PROXY_AVAILABLE = True
|
|
32
|
+
except ImportError:
|
|
33
|
+
PROXY_MISSING_DEPS.append("mitmproxy")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# Default ports
|
|
37
|
+
MOLTBOT_DEFAULT_PORT = 18789
|
|
38
|
+
TWEEK_DEFAULT_PORT = 9877
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class ProxyConflict:
|
|
43
|
+
"""Information about a detected proxy conflict."""
|
|
44
|
+
tool_name: str
|
|
45
|
+
port: int
|
|
46
|
+
is_running: bool
|
|
47
|
+
description: str
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def is_port_in_use(port: int, host: str = "127.0.0.1") -> bool:
|
|
51
|
+
"""Check if a port is currently in use."""
|
|
52
|
+
try:
|
|
53
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
54
|
+
s.settimeout(1)
|
|
55
|
+
result = s.connect_ex((host, port))
|
|
56
|
+
return result == 0
|
|
57
|
+
except (socket.error, OSError):
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def check_moltbot_gateway_running(port: int = MOLTBOT_DEFAULT_PORT) -> bool:
|
|
62
|
+
"""Check if moltbot's gateway is actively listening on its port."""
|
|
63
|
+
return is_port_in_use(port)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def detect_proxy_conflicts() -> list[ProxyConflict]:
|
|
67
|
+
"""
|
|
68
|
+
Detect any running proxies that might conflict with Tweek.
|
|
69
|
+
|
|
70
|
+
Returns a list of detected conflicts with details about each.
|
|
71
|
+
"""
|
|
72
|
+
conflicts = []
|
|
73
|
+
|
|
74
|
+
# Check for moltbot
|
|
75
|
+
moltbot_info = detect_moltbot()
|
|
76
|
+
if moltbot_info:
|
|
77
|
+
moltbot_port = moltbot_info.get("gateway_port", MOLTBOT_DEFAULT_PORT)
|
|
78
|
+
is_running = check_moltbot_gateway_running(moltbot_port)
|
|
79
|
+
|
|
80
|
+
if moltbot_info.get("process_running") or is_running:
|
|
81
|
+
conflicts.append(ProxyConflict(
|
|
82
|
+
tool_name="moltbot",
|
|
83
|
+
port=moltbot_port,
|
|
84
|
+
is_running=is_running,
|
|
85
|
+
description="Moltbot gateway detected" +
|
|
86
|
+
(f" on port {moltbot_port}" if is_running else " (process found)")
|
|
87
|
+
))
|
|
88
|
+
|
|
89
|
+
# Check if something else is using Tweek's default port
|
|
90
|
+
if is_port_in_use(TWEEK_DEFAULT_PORT):
|
|
91
|
+
conflicts.append(ProxyConflict(
|
|
92
|
+
tool_name="unknown",
|
|
93
|
+
port=TWEEK_DEFAULT_PORT,
|
|
94
|
+
is_running=True,
|
|
95
|
+
description=f"Port {TWEEK_DEFAULT_PORT} is already in use"
|
|
96
|
+
))
|
|
97
|
+
|
|
98
|
+
return conflicts
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def get_moltbot_status() -> dict:
|
|
102
|
+
"""
|
|
103
|
+
Get detailed moltbot status including whether its gateway is running.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
Dict with keys: installed, running, gateway_active, port, config_path
|
|
107
|
+
"""
|
|
108
|
+
from pathlib import Path
|
|
109
|
+
|
|
110
|
+
moltbot_info = detect_moltbot()
|
|
111
|
+
|
|
112
|
+
status = {
|
|
113
|
+
"installed": moltbot_info is not None,
|
|
114
|
+
"running": False,
|
|
115
|
+
"gateway_active": False,
|
|
116
|
+
"port": MOLTBOT_DEFAULT_PORT,
|
|
117
|
+
"config_path": None,
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if moltbot_info:
|
|
121
|
+
status["running"] = moltbot_info.get("process_running", False)
|
|
122
|
+
status["port"] = moltbot_info.get("gateway_port", MOLTBOT_DEFAULT_PORT)
|
|
123
|
+
status["gateway_active"] = check_moltbot_gateway_running(status["port"])
|
|
124
|
+
|
|
125
|
+
config_path = Path.home() / ".moltbot"
|
|
126
|
+
if config_path.exists():
|
|
127
|
+
status["config_path"] = str(config_path)
|
|
128
|
+
|
|
129
|
+
return status
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# Detection functions for supported tools
|
|
133
|
+
def detect_moltbot() -> Optional[dict]:
|
|
134
|
+
"""Detect if moltbot is installed on the system."""
|
|
135
|
+
import subprocess
|
|
136
|
+
import json
|
|
137
|
+
from pathlib import Path
|
|
138
|
+
|
|
139
|
+
indicators = {
|
|
140
|
+
"npm_global": False,
|
|
141
|
+
"process_running": False,
|
|
142
|
+
"config_exists": False,
|
|
143
|
+
"gateway_port": None,
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
# Check for npm global installation
|
|
147
|
+
try:
|
|
148
|
+
result = subprocess.run(
|
|
149
|
+
["npm", "list", "-g", "moltbot", "--json"],
|
|
150
|
+
capture_output=True,
|
|
151
|
+
text=True,
|
|
152
|
+
timeout=5
|
|
153
|
+
)
|
|
154
|
+
if result.returncode == 0:
|
|
155
|
+
data = json.loads(result.stdout)
|
|
156
|
+
if "dependencies" in data and "moltbot" in data.get("dependencies", {}):
|
|
157
|
+
indicators["npm_global"] = True
|
|
158
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, json.JSONDecodeError):
|
|
159
|
+
pass
|
|
160
|
+
|
|
161
|
+
# Check for running moltbot process
|
|
162
|
+
try:
|
|
163
|
+
result = subprocess.run(
|
|
164
|
+
["pgrep", "-f", "moltbot"],
|
|
165
|
+
capture_output=True,
|
|
166
|
+
timeout=5
|
|
167
|
+
)
|
|
168
|
+
if result.returncode == 0:
|
|
169
|
+
indicators["process_running"] = True
|
|
170
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
171
|
+
pass
|
|
172
|
+
|
|
173
|
+
# Check for moltbot config directory
|
|
174
|
+
moltbot_config = Path.home() / ".moltbot"
|
|
175
|
+
if moltbot_config.exists():
|
|
176
|
+
indicators["config_exists"] = True
|
|
177
|
+
|
|
178
|
+
# Default gateway port
|
|
179
|
+
indicators["gateway_port"] = 18789
|
|
180
|
+
|
|
181
|
+
if any([indicators["npm_global"], indicators["process_running"], indicators["config_exists"]]):
|
|
182
|
+
return indicators
|
|
183
|
+
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def detect_cursor() -> Optional[dict]:
|
|
188
|
+
"""Detect if Cursor IDE is installed."""
|
|
189
|
+
from pathlib import Path
|
|
190
|
+
import platform
|
|
191
|
+
|
|
192
|
+
system = platform.system()
|
|
193
|
+
|
|
194
|
+
if system == "Darwin":
|
|
195
|
+
cursor_app = Path("/Applications/Cursor.app")
|
|
196
|
+
cursor_config = Path.home() / "Library/Application Support/Cursor"
|
|
197
|
+
elif system == "Linux":
|
|
198
|
+
cursor_app = Path.home() / ".local/share/applications/cursor.desktop"
|
|
199
|
+
cursor_config = Path.home() / ".config/Cursor"
|
|
200
|
+
else:
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
if cursor_app.exists() or cursor_config.exists():
|
|
204
|
+
return {
|
|
205
|
+
"app_exists": cursor_app.exists(),
|
|
206
|
+
"config_exists": cursor_config.exists(),
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def detect_continue() -> Optional[dict]:
|
|
213
|
+
"""Detect if Continue.dev extension is installed."""
|
|
214
|
+
from pathlib import Path
|
|
215
|
+
|
|
216
|
+
# Check VS Code extensions
|
|
217
|
+
vscode_ext = Path.home() / ".vscode/extensions"
|
|
218
|
+
continue_pattern = "continue.continue-*"
|
|
219
|
+
|
|
220
|
+
if vscode_ext.exists():
|
|
221
|
+
matches = list(vscode_ext.glob(continue_pattern))
|
|
222
|
+
if matches:
|
|
223
|
+
return {
|
|
224
|
+
"extension_path": str(matches[0]),
|
|
225
|
+
"version": matches[0].name.split("-")[-1] if "-" in matches[0].name else "unknown",
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def detect_supported_tools() -> dict:
|
|
232
|
+
"""Detect all supported LLM tools on the system."""
|
|
233
|
+
return {
|
|
234
|
+
"moltbot": detect_moltbot(),
|
|
235
|
+
"cursor": detect_cursor(),
|
|
236
|
+
"continue": detect_continue(),
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def get_proxy_status() -> dict:
|
|
241
|
+
"""Get current proxy status."""
|
|
242
|
+
from pathlib import Path
|
|
243
|
+
import yaml
|
|
244
|
+
|
|
245
|
+
config_path = Path.home() / ".tweek" / "config.yaml"
|
|
246
|
+
|
|
247
|
+
status = {
|
|
248
|
+
"available": PROXY_AVAILABLE,
|
|
249
|
+
"missing_deps": PROXY_MISSING_DEPS,
|
|
250
|
+
"enabled": False,
|
|
251
|
+
"running": False,
|
|
252
|
+
"port": 9877,
|
|
253
|
+
"ca_trusted": False,
|
|
254
|
+
"detected_tools": detect_supported_tools(),
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if config_path.exists():
|
|
258
|
+
try:
|
|
259
|
+
with open(config_path) as f:
|
|
260
|
+
config = yaml.safe_load(f) or {}
|
|
261
|
+
proxy_config = config.get("proxy", {})
|
|
262
|
+
status["enabled"] = proxy_config.get("enabled", False)
|
|
263
|
+
status["port"] = proxy_config.get("port", 9877)
|
|
264
|
+
except Exception:
|
|
265
|
+
pass
|
|
266
|
+
|
|
267
|
+
# Check if proxy process is running
|
|
268
|
+
if PROXY_AVAILABLE:
|
|
269
|
+
try:
|
|
270
|
+
import subprocess
|
|
271
|
+
result = subprocess.run(
|
|
272
|
+
["pgrep", "-f", "tweek.*proxy"],
|
|
273
|
+
capture_output=True,
|
|
274
|
+
timeout=5
|
|
275
|
+
)
|
|
276
|
+
status["running"] = result.returncode == 0
|
|
277
|
+
except Exception:
|
|
278
|
+
pass
|
|
279
|
+
|
|
280
|
+
# Check if CA certificate is trusted
|
|
281
|
+
ca_cert = Path.home() / ".tweek" / "proxy" / "tweek-ca.pem"
|
|
282
|
+
status["ca_trusted"] = ca_cert.exists() # Simplified check
|
|
283
|
+
|
|
284
|
+
return status
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
__all__ = [
|
|
288
|
+
"PROXY_AVAILABLE",
|
|
289
|
+
"PROXY_MISSING_DEPS",
|
|
290
|
+
"MOLTBOT_DEFAULT_PORT",
|
|
291
|
+
"TWEEK_DEFAULT_PORT",
|
|
292
|
+
"ProxyConflict",
|
|
293
|
+
"is_port_in_use",
|
|
294
|
+
"check_moltbot_gateway_running",
|
|
295
|
+
"detect_proxy_conflicts",
|
|
296
|
+
"get_moltbot_status",
|
|
297
|
+
"detect_moltbot",
|
|
298
|
+
"detect_cursor",
|
|
299
|
+
"detect_continue",
|
|
300
|
+
"detect_supported_tools",
|
|
301
|
+
"get_proxy_status",
|
|
302
|
+
]
|
tweek/proxy/addon.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Mitmproxy Addon - The actual proxy implementation.
|
|
3
|
+
|
|
4
|
+
This module provides the mitmproxy addon that intercepts LLM API traffic
|
|
5
|
+
and applies Tweek's security screening.
|
|
6
|
+
|
|
7
|
+
Requires: pip install tweek[proxy]
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
# Guard import - only available with [proxy] extra
|
|
18
|
+
try:
|
|
19
|
+
from mitmproxy import http, ctx
|
|
20
|
+
from mitmproxy.script import concurrent
|
|
21
|
+
MITMPROXY_AVAILABLE = True
|
|
22
|
+
except ImportError:
|
|
23
|
+
MITMPROXY_AVAILABLE = False
|
|
24
|
+
# Stub for type checking
|
|
25
|
+
class http:
|
|
26
|
+
class HTTPFlow:
|
|
27
|
+
pass
|
|
28
|
+
ctx = None
|
|
29
|
+
def concurrent(f):
|
|
30
|
+
return f
|
|
31
|
+
|
|
32
|
+
from .interceptor import LLMAPIInterceptor, LLMProvider
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger("tweek.proxy")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TweekProxyAddon:
|
|
38
|
+
"""
|
|
39
|
+
Mitmproxy addon for Tweek LLM security screening.
|
|
40
|
+
|
|
41
|
+
This addon intercepts HTTPS traffic to LLM APIs and screens
|
|
42
|
+
both requests (for prompt injection) and responses (for dangerous tool calls).
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
pattern_matcher=None,
|
|
48
|
+
security_logger=None,
|
|
49
|
+
block_mode: bool = True,
|
|
50
|
+
log_only: bool = False,
|
|
51
|
+
):
|
|
52
|
+
"""
|
|
53
|
+
Initialize the Tweek proxy addon.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
pattern_matcher: PatternMatcher instance for screening
|
|
57
|
+
security_logger: SecurityLogger for audit logging
|
|
58
|
+
block_mode: If True, block dangerous responses. If False, just log.
|
|
59
|
+
log_only: If True, log all traffic without blocking.
|
|
60
|
+
"""
|
|
61
|
+
self.interceptor = LLMAPIInterceptor(
|
|
62
|
+
pattern_matcher=pattern_matcher,
|
|
63
|
+
security_logger=security_logger,
|
|
64
|
+
)
|
|
65
|
+
self.block_mode = block_mode
|
|
66
|
+
self.log_only = log_only
|
|
67
|
+
self.stats = {
|
|
68
|
+
"requests_screened": 0,
|
|
69
|
+
"responses_screened": 0,
|
|
70
|
+
"requests_blocked": 0,
|
|
71
|
+
"responses_blocked": 0,
|
|
72
|
+
"tool_calls_detected": 0,
|
|
73
|
+
"tool_calls_blocked": 0,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
def load(self, loader):
|
|
77
|
+
"""Called when the addon is loaded."""
|
|
78
|
+
if ctx:
|
|
79
|
+
ctx.log.info("Tweek LLM Security Proxy loaded")
|
|
80
|
+
ctx.log.info(f"Block mode: {self.block_mode}")
|
|
81
|
+
ctx.log.info(f"Log only: {self.log_only}")
|
|
82
|
+
|
|
83
|
+
def request(self, flow: http.HTTPFlow):
|
|
84
|
+
"""Handle outgoing requests to LLM APIs."""
|
|
85
|
+
host = flow.request.host
|
|
86
|
+
|
|
87
|
+
if not self.interceptor.should_intercept(host):
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
self.stats["requests_screened"] += 1
|
|
91
|
+
provider = self.interceptor.identify_provider(host)
|
|
92
|
+
|
|
93
|
+
# Screen the request for prompt injection
|
|
94
|
+
if flow.request.content:
|
|
95
|
+
result = self.interceptor.screen_request(flow.request.content, provider)
|
|
96
|
+
|
|
97
|
+
if result.warnings:
|
|
98
|
+
# Log warnings but don't block requests
|
|
99
|
+
logger.warning(
|
|
100
|
+
f"Prompt injection warning: {result.warnings} "
|
|
101
|
+
f"(provider={provider.value}, path={flow.request.path})"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# Add header to track warning
|
|
105
|
+
flow.request.headers["X-Tweek-Warning"] = "prompt-injection-suspected"
|
|
106
|
+
|
|
107
|
+
@concurrent
|
|
108
|
+
def response(self, flow: http.HTTPFlow):
|
|
109
|
+
"""Handle incoming responses from LLM APIs."""
|
|
110
|
+
host = flow.request.host
|
|
111
|
+
|
|
112
|
+
if not self.interceptor.should_intercept(host):
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
# Skip streaming responses - we can't buffer them without breaking UX
|
|
116
|
+
content_type = flow.response.headers.get("content-type", "")
|
|
117
|
+
if "text/event-stream" in content_type:
|
|
118
|
+
logger.debug(f"Skipping streaming response from {host}")
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
self.stats["responses_screened"] += 1
|
|
122
|
+
provider = self.interceptor.identify_provider(host)
|
|
123
|
+
|
|
124
|
+
if not flow.response.content:
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
# Screen the response for dangerous tool calls
|
|
128
|
+
result = self.interceptor.screen_response(flow.response.content, provider)
|
|
129
|
+
|
|
130
|
+
if result.blocked_tools:
|
|
131
|
+
self.stats["tool_calls_detected"] += len(result.blocked_tools)
|
|
132
|
+
|
|
133
|
+
if not result.allowed:
|
|
134
|
+
self.stats["responses_blocked"] += 1
|
|
135
|
+
self.stats["tool_calls_blocked"] += len(result.blocked_tools)
|
|
136
|
+
|
|
137
|
+
logger.warning(
|
|
138
|
+
f"BLOCKED: {result.reason} "
|
|
139
|
+
f"(provider={provider.value}, patterns={result.matched_patterns})"
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
if self.block_mode and not self.log_only:
|
|
143
|
+
# Replace response with error
|
|
144
|
+
flow.response = http.Response.make(
|
|
145
|
+
403,
|
|
146
|
+
json.dumps({
|
|
147
|
+
"error": {
|
|
148
|
+
"type": "security_blocked",
|
|
149
|
+
"message": f"Tweek Security: {result.reason}",
|
|
150
|
+
"blocked_tools": result.blocked_tools,
|
|
151
|
+
"patterns": result.matched_patterns,
|
|
152
|
+
}
|
|
153
|
+
}),
|
|
154
|
+
{"Content-Type": "application/json"},
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
elif result.warnings:
|
|
158
|
+
logger.info(f"Warnings for response: {result.warnings}")
|
|
159
|
+
|
|
160
|
+
def done(self):
|
|
161
|
+
"""Called when the proxy shuts down."""
|
|
162
|
+
logger.info(f"Tweek Proxy Stats: {json.dumps(self.stats, indent=2)}")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def create_addon(
|
|
166
|
+
pattern_matcher=None,
|
|
167
|
+
security_logger=None,
|
|
168
|
+
block_mode: bool = True,
|
|
169
|
+
log_only: bool = False,
|
|
170
|
+
) -> TweekProxyAddon:
|
|
171
|
+
"""
|
|
172
|
+
Factory function to create a configured Tweek proxy addon.
|
|
173
|
+
|
|
174
|
+
This is the entry point used by mitmproxy when loading the script.
|
|
175
|
+
"""
|
|
176
|
+
return TweekProxyAddon(
|
|
177
|
+
pattern_matcher=pattern_matcher,
|
|
178
|
+
security_logger=security_logger,
|
|
179
|
+
block_mode=block_mode,
|
|
180
|
+
log_only=log_only,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# For direct script loading by mitmproxy
|
|
185
|
+
addons = []
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def configure_and_load():
|
|
189
|
+
"""
|
|
190
|
+
Configure and load the addon with Tweek's pattern matcher.
|
|
191
|
+
|
|
192
|
+
Called when loading as a mitmproxy script.
|
|
193
|
+
"""
|
|
194
|
+
global addons
|
|
195
|
+
|
|
196
|
+
# Try to load Tweek's pattern matcher
|
|
197
|
+
pattern_matcher = None
|
|
198
|
+
security_logger = None
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
from tweek.hooks.patterns import PatternMatcher
|
|
202
|
+
pattern_matcher = PatternMatcher()
|
|
203
|
+
except ImportError:
|
|
204
|
+
logger.warning("Could not load PatternMatcher, running in pass-through mode")
|
|
205
|
+
|
|
206
|
+
try:
|
|
207
|
+
from tweek.logging.security_log import SecurityLogger
|
|
208
|
+
security_logger = SecurityLogger()
|
|
209
|
+
except ImportError:
|
|
210
|
+
logger.warning("Could not load SecurityLogger")
|
|
211
|
+
|
|
212
|
+
addon = create_addon(
|
|
213
|
+
pattern_matcher=pattern_matcher,
|
|
214
|
+
security_logger=security_logger,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
addons = [addon]
|
|
218
|
+
return addons
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# Auto-configure when loaded as script
|
|
222
|
+
if MITMPROXY_AVAILABLE:
|
|
223
|
+
configure_and_load()
|