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,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
@@ -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()