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,313 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LLM API Interceptor - Screens requests and responses to LLM APIs.
|
|
3
|
+
|
|
4
|
+
This module provides the core interception logic for the Tweek proxy,
|
|
5
|
+
analyzing LLM API traffic for security threats.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
import uuid
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Optional, Any
|
|
13
|
+
from enum import Enum
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LLMProvider(Enum):
|
|
17
|
+
"""Supported LLM API providers."""
|
|
18
|
+
ANTHROPIC = "anthropic"
|
|
19
|
+
OPENAI = "openai"
|
|
20
|
+
GOOGLE = "google"
|
|
21
|
+
BEDROCK = "bedrock"
|
|
22
|
+
UNKNOWN = "unknown"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class InterceptionResult:
|
|
27
|
+
"""Result of screening an LLM API request or response."""
|
|
28
|
+
allowed: bool
|
|
29
|
+
provider: LLMProvider
|
|
30
|
+
reason: Optional[str] = None
|
|
31
|
+
blocked_tools: list[str] = field(default_factory=list)
|
|
32
|
+
warnings: list[str] = field(default_factory=list)
|
|
33
|
+
matched_patterns: list[str] = field(default_factory=list)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class ToolCall:
|
|
38
|
+
"""Represents a tool call extracted from an LLM response."""
|
|
39
|
+
id: str
|
|
40
|
+
name: str
|
|
41
|
+
input: dict[str, Any]
|
|
42
|
+
provider: LLMProvider
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class LLMAPIInterceptor:
|
|
46
|
+
"""
|
|
47
|
+
Intercepts and screens LLM API traffic.
|
|
48
|
+
|
|
49
|
+
Analyzes both requests (prompts) and responses (tool calls)
|
|
50
|
+
for security threats using Tweek's pattern matching engine.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
# API endpoints to monitor
|
|
54
|
+
MONITORED_HOSTS = {
|
|
55
|
+
"api.anthropic.com": LLMProvider.ANTHROPIC,
|
|
56
|
+
"api.openai.com": LLMProvider.OPENAI,
|
|
57
|
+
"generativelanguage.googleapis.com": LLMProvider.GOOGLE,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# Bedrock uses regional endpoints
|
|
61
|
+
BEDROCK_PATTERN = re.compile(r"bedrock-runtime\.[\w-]+\.amazonaws\.com")
|
|
62
|
+
|
|
63
|
+
def __init__(self, pattern_matcher=None, security_logger=None):
|
|
64
|
+
"""
|
|
65
|
+
Initialize the interceptor.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
pattern_matcher: Tweek PatternMatcher instance for screening
|
|
69
|
+
security_logger: SecurityLogger for audit logging
|
|
70
|
+
"""
|
|
71
|
+
self.pattern_matcher = pattern_matcher
|
|
72
|
+
self.security_logger = security_logger
|
|
73
|
+
|
|
74
|
+
def _new_correlation_id(self) -> str:
|
|
75
|
+
"""Generate a new correlation ID for a screening pass."""
|
|
76
|
+
return uuid.uuid4().hex[:12]
|
|
77
|
+
|
|
78
|
+
def identify_provider(self, host: str) -> LLMProvider:
|
|
79
|
+
"""Identify the LLM provider from the request host."""
|
|
80
|
+
if host in self.MONITORED_HOSTS:
|
|
81
|
+
return self.MONITORED_HOSTS[host]
|
|
82
|
+
|
|
83
|
+
if self.BEDROCK_PATTERN.match(host):
|
|
84
|
+
return LLMProvider.BEDROCK
|
|
85
|
+
|
|
86
|
+
return LLMProvider.UNKNOWN
|
|
87
|
+
|
|
88
|
+
def should_intercept(self, host: str) -> bool:
|
|
89
|
+
"""Check if this host should be intercepted."""
|
|
90
|
+
return self.identify_provider(host) != LLMProvider.UNKNOWN
|
|
91
|
+
|
|
92
|
+
def extract_tool_calls_anthropic(self, response: dict) -> list[ToolCall]:
|
|
93
|
+
"""Extract tool calls from Anthropic API response."""
|
|
94
|
+
tool_calls = []
|
|
95
|
+
|
|
96
|
+
content = response.get("content", [])
|
|
97
|
+
for block in content:
|
|
98
|
+
if block.get("type") == "tool_use":
|
|
99
|
+
tool_calls.append(ToolCall(
|
|
100
|
+
id=block.get("id", ""),
|
|
101
|
+
name=block.get("name", ""),
|
|
102
|
+
input=block.get("input", {}),
|
|
103
|
+
provider=LLMProvider.ANTHROPIC,
|
|
104
|
+
))
|
|
105
|
+
|
|
106
|
+
return tool_calls
|
|
107
|
+
|
|
108
|
+
def extract_tool_calls_openai(self, response: dict) -> list[ToolCall]:
|
|
109
|
+
"""Extract tool calls from OpenAI API response."""
|
|
110
|
+
tool_calls = []
|
|
111
|
+
|
|
112
|
+
choices = response.get("choices", [])
|
|
113
|
+
for choice in choices:
|
|
114
|
+
message = choice.get("message", {})
|
|
115
|
+
for tc in message.get("tool_calls", []):
|
|
116
|
+
func = tc.get("function", {})
|
|
117
|
+
try:
|
|
118
|
+
args = json.loads(func.get("arguments", "{}"))
|
|
119
|
+
except json.JSONDecodeError:
|
|
120
|
+
args = {"_raw": func.get("arguments", "")}
|
|
121
|
+
|
|
122
|
+
tool_calls.append(ToolCall(
|
|
123
|
+
id=tc.get("id", ""),
|
|
124
|
+
name=func.get("name", ""),
|
|
125
|
+
input=args,
|
|
126
|
+
provider=LLMProvider.OPENAI,
|
|
127
|
+
))
|
|
128
|
+
|
|
129
|
+
return tool_calls
|
|
130
|
+
|
|
131
|
+
def extract_tool_calls(self, response: dict, provider: LLMProvider) -> list[ToolCall]:
|
|
132
|
+
"""Extract tool calls from an LLM API response."""
|
|
133
|
+
if provider == LLMProvider.ANTHROPIC:
|
|
134
|
+
return self.extract_tool_calls_anthropic(response)
|
|
135
|
+
elif provider == LLMProvider.OPENAI:
|
|
136
|
+
return self.extract_tool_calls_openai(response)
|
|
137
|
+
else:
|
|
138
|
+
return []
|
|
139
|
+
|
|
140
|
+
def screen_tool_call(self, tool_call: ToolCall) -> InterceptionResult:
|
|
141
|
+
"""Screen a single tool call for security threats."""
|
|
142
|
+
if not self.pattern_matcher:
|
|
143
|
+
return InterceptionResult(allowed=True, provider=tool_call.provider)
|
|
144
|
+
|
|
145
|
+
# Build command string for pattern matching
|
|
146
|
+
# Handle common tool patterns
|
|
147
|
+
command = ""
|
|
148
|
+
tool_name = tool_call.name.lower()
|
|
149
|
+
|
|
150
|
+
if tool_name in ("bash", "shell", "execute", "run_command"):
|
|
151
|
+
command = tool_call.input.get("command", "")
|
|
152
|
+
elif tool_name in ("read", "read_file", "cat"):
|
|
153
|
+
command = f"cat {tool_call.input.get('path', tool_call.input.get('file_path', ''))}"
|
|
154
|
+
elif tool_name in ("write", "write_file"):
|
|
155
|
+
path = tool_call.input.get('path', tool_call.input.get('file_path', ''))
|
|
156
|
+
command = f"write to {path}"
|
|
157
|
+
elif tool_name in ("fetch", "web_fetch", "curl", "http"):
|
|
158
|
+
url = tool_call.input.get('url', '')
|
|
159
|
+
command = f"curl {url}"
|
|
160
|
+
else:
|
|
161
|
+
# Generic: serialize input for pattern matching
|
|
162
|
+
command = json.dumps(tool_call.input)
|
|
163
|
+
|
|
164
|
+
# Run through pattern matcher
|
|
165
|
+
matches = self.pattern_matcher.match(command)
|
|
166
|
+
|
|
167
|
+
if matches:
|
|
168
|
+
# Log the blocked attempt
|
|
169
|
+
if self.security_logger:
|
|
170
|
+
self.security_logger.log_event(
|
|
171
|
+
event_type="proxy_block",
|
|
172
|
+
tool=tool_call.name,
|
|
173
|
+
command=command[:500], # Truncate for logging
|
|
174
|
+
patterns=matches,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
return InterceptionResult(
|
|
178
|
+
allowed=False,
|
|
179
|
+
provider=tool_call.provider,
|
|
180
|
+
reason=f"Blocked by patterns: {', '.join(matches)}",
|
|
181
|
+
blocked_tools=[tool_call.name],
|
|
182
|
+
matched_patterns=matches,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
return InterceptionResult(allowed=True, provider=tool_call.provider)
|
|
186
|
+
|
|
187
|
+
def screen_response(self, response_body: bytes, provider: LLMProvider) -> InterceptionResult:
|
|
188
|
+
"""
|
|
189
|
+
Screen an LLM API response for dangerous tool calls.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
response_body: Raw response body bytes
|
|
193
|
+
provider: The LLM provider
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
InterceptionResult with screening decision
|
|
197
|
+
"""
|
|
198
|
+
correlation_id = self._new_correlation_id()
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
response = json.loads(response_body)
|
|
202
|
+
except json.JSONDecodeError:
|
|
203
|
+
# Can't parse, allow through
|
|
204
|
+
return InterceptionResult(allowed=True, provider=provider)
|
|
205
|
+
|
|
206
|
+
tool_calls = self.extract_tool_calls(response, provider)
|
|
207
|
+
|
|
208
|
+
if not tool_calls:
|
|
209
|
+
return InterceptionResult(allowed=True, provider=provider)
|
|
210
|
+
|
|
211
|
+
# Screen each tool call
|
|
212
|
+
blocked_tools = []
|
|
213
|
+
all_patterns = []
|
|
214
|
+
all_warnings = []
|
|
215
|
+
|
|
216
|
+
for tc in tool_calls:
|
|
217
|
+
result = self.screen_tool_call(tc)
|
|
218
|
+
if not result.allowed:
|
|
219
|
+
blocked_tools.extend(result.blocked_tools)
|
|
220
|
+
all_patterns.extend(result.matched_patterns)
|
|
221
|
+
all_warnings.extend(result.warnings)
|
|
222
|
+
|
|
223
|
+
if blocked_tools:
|
|
224
|
+
self._log_proxy_event(
|
|
225
|
+
"block", blocked_tools, all_patterns, provider, correlation_id
|
|
226
|
+
)
|
|
227
|
+
return InterceptionResult(
|
|
228
|
+
allowed=False,
|
|
229
|
+
provider=provider,
|
|
230
|
+
reason=f"Blocked dangerous tool calls: {', '.join(blocked_tools)}",
|
|
231
|
+
blocked_tools=blocked_tools,
|
|
232
|
+
matched_patterns=all_patterns,
|
|
233
|
+
warnings=all_warnings,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
return InterceptionResult(
|
|
237
|
+
allowed=True,
|
|
238
|
+
provider=provider,
|
|
239
|
+
warnings=all_warnings,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
def _log_proxy_event(
|
|
243
|
+
self,
|
|
244
|
+
decision: str,
|
|
245
|
+
blocked_tools: list,
|
|
246
|
+
matched_patterns: list,
|
|
247
|
+
provider: LLMProvider,
|
|
248
|
+
correlation_id: str,
|
|
249
|
+
):
|
|
250
|
+
"""Log a proxy screening event to the security logger."""
|
|
251
|
+
try:
|
|
252
|
+
from tweek.logging.security_log import get_logger, SecurityEvent, EventType
|
|
253
|
+
get_logger().log(SecurityEvent(
|
|
254
|
+
event_type=EventType.PROXY_EVENT,
|
|
255
|
+
tool_name="http_proxy",
|
|
256
|
+
decision=decision,
|
|
257
|
+
decision_reason=f"Blocked tools: {', '.join(blocked_tools)}" if blocked_tools else None,
|
|
258
|
+
metadata={
|
|
259
|
+
"provider": provider.value,
|
|
260
|
+
"blocked_tools": blocked_tools,
|
|
261
|
+
"matched_patterns": matched_patterns,
|
|
262
|
+
},
|
|
263
|
+
correlation_id=correlation_id,
|
|
264
|
+
source="http_proxy",
|
|
265
|
+
))
|
|
266
|
+
except Exception:
|
|
267
|
+
pass
|
|
268
|
+
|
|
269
|
+
def screen_request(self, request_body: bytes, provider: LLMProvider) -> InterceptionResult:
|
|
270
|
+
"""
|
|
271
|
+
Screen an LLM API request for prompt injection attempts.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
request_body: Raw request body bytes
|
|
275
|
+
provider: The LLM provider
|
|
276
|
+
|
|
277
|
+
Returns:
|
|
278
|
+
InterceptionResult with screening decision
|
|
279
|
+
"""
|
|
280
|
+
if not self.pattern_matcher:
|
|
281
|
+
return InterceptionResult(allowed=True, provider=provider)
|
|
282
|
+
|
|
283
|
+
try:
|
|
284
|
+
request = json.loads(request_body)
|
|
285
|
+
except json.JSONDecodeError:
|
|
286
|
+
return InterceptionResult(allowed=True, provider=provider)
|
|
287
|
+
|
|
288
|
+
# Extract user messages for screening
|
|
289
|
+
messages = []
|
|
290
|
+
|
|
291
|
+
if provider == LLMProvider.ANTHROPIC:
|
|
292
|
+
messages = request.get("messages", [])
|
|
293
|
+
elif provider == LLMProvider.OPENAI:
|
|
294
|
+
messages = request.get("messages", [])
|
|
295
|
+
|
|
296
|
+
# Screen each user message
|
|
297
|
+
warnings = []
|
|
298
|
+
|
|
299
|
+
for msg in messages:
|
|
300
|
+
if msg.get("role") == "user":
|
|
301
|
+
content = msg.get("content", "")
|
|
302
|
+
if isinstance(content, str):
|
|
303
|
+
# Check for prompt injection patterns
|
|
304
|
+
# Note: This is advisory only, we don't block requests
|
|
305
|
+
matches = self.pattern_matcher.match_prompt_injection(content)
|
|
306
|
+
if matches:
|
|
307
|
+
warnings.append(f"Potential prompt injection: {matches}")
|
|
308
|
+
|
|
309
|
+
return InterceptionResult(
|
|
310
|
+
allowed=True, # Requests are allowed, just warned
|
|
311
|
+
provider=provider,
|
|
312
|
+
warnings=warnings,
|
|
313
|
+
)
|
tweek/proxy/server.py
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Proxy Server Management - Start, stop, and manage the Tweek proxy.
|
|
3
|
+
|
|
4
|
+
This module handles the lifecycle of the mitmproxy-based security proxy.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import signal
|
|
12
|
+
import subprocess
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional
|
|
16
|
+
import logging
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger("tweek.proxy")
|
|
19
|
+
|
|
20
|
+
# Default configuration
|
|
21
|
+
DEFAULT_PORT = 9877
|
|
22
|
+
DEFAULT_WEB_PORT = 9878 # mitmproxy web interface
|
|
23
|
+
PID_FILE = Path.home() / ".tweek" / "proxy" / "proxy.pid"
|
|
24
|
+
LOG_FILE = Path.home() / ".tweek" / "proxy" / "proxy.log"
|
|
25
|
+
CA_DIR = Path.home() / ".tweek" / "proxy" / "certs"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def ensure_directories():
|
|
29
|
+
"""Ensure proxy directories exist."""
|
|
30
|
+
PID_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
CA_DIR.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_addon_script_path() -> Path:
|
|
35
|
+
"""Get the path to the mitmproxy addon script."""
|
|
36
|
+
return Path(__file__).parent / "addon.py"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def is_proxy_running() -> tuple[bool, Optional[int]]:
|
|
40
|
+
"""Check if the proxy is running and return its PID."""
|
|
41
|
+
if not PID_FILE.exists():
|
|
42
|
+
return False, None
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
pid = int(PID_FILE.read_text().strip())
|
|
46
|
+
|
|
47
|
+
# Check if process exists
|
|
48
|
+
os.kill(pid, 0)
|
|
49
|
+
return True, pid
|
|
50
|
+
except (ValueError, ProcessLookupError, PermissionError):
|
|
51
|
+
# Clean up stale PID file
|
|
52
|
+
PID_FILE.unlink(missing_ok=True)
|
|
53
|
+
return False, None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def start_proxy(
|
|
57
|
+
port: int = DEFAULT_PORT,
|
|
58
|
+
web_port: Optional[int] = None,
|
|
59
|
+
block_mode: bool = True,
|
|
60
|
+
log_only: bool = False,
|
|
61
|
+
foreground: bool = False,
|
|
62
|
+
) -> tuple[bool, str]:
|
|
63
|
+
"""
|
|
64
|
+
Start the Tweek proxy server.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
port: Port for the proxy to listen on
|
|
68
|
+
web_port: Port for mitmproxy web interface (None to disable)
|
|
69
|
+
block_mode: If True, block dangerous responses
|
|
70
|
+
log_only: If True, log only without blocking
|
|
71
|
+
foreground: If True, run in foreground (for debugging)
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
Tuple of (success, message)
|
|
75
|
+
"""
|
|
76
|
+
# Check if already running
|
|
77
|
+
running, pid = is_proxy_running()
|
|
78
|
+
if running:
|
|
79
|
+
return False, f"Proxy already running (PID {pid})"
|
|
80
|
+
|
|
81
|
+
# Check for mitmproxy
|
|
82
|
+
try:
|
|
83
|
+
import mitmproxy
|
|
84
|
+
except ImportError:
|
|
85
|
+
return False, "mitmproxy not installed. Run: pip install tweek[proxy]"
|
|
86
|
+
|
|
87
|
+
ensure_directories()
|
|
88
|
+
|
|
89
|
+
# Build mitmproxy command
|
|
90
|
+
addon_path = get_addon_script_path()
|
|
91
|
+
|
|
92
|
+
cmd = [
|
|
93
|
+
sys.executable, "-m", "mitmproxy.tools.main",
|
|
94
|
+
"--mode", "regular",
|
|
95
|
+
"--listen-port", str(port),
|
|
96
|
+
"--set", f"confdir={CA_DIR}",
|
|
97
|
+
"--scripts", str(addon_path),
|
|
98
|
+
"--quiet", # Reduce noise
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
if web_port:
|
|
102
|
+
cmd.extend(["--web-port", str(web_port)])
|
|
103
|
+
else:
|
|
104
|
+
cmd.append("--no-web-open-browser")
|
|
105
|
+
|
|
106
|
+
# Set environment for addon configuration
|
|
107
|
+
env = os.environ.copy()
|
|
108
|
+
env["TWEEK_PROXY_BLOCK_MODE"] = "1" if block_mode else "0"
|
|
109
|
+
env["TWEEK_PROXY_LOG_ONLY"] = "1" if log_only else "0"
|
|
110
|
+
|
|
111
|
+
if foreground:
|
|
112
|
+
# Run in foreground for debugging
|
|
113
|
+
try:
|
|
114
|
+
subprocess.run(cmd, env=env)
|
|
115
|
+
return True, "Proxy stopped"
|
|
116
|
+
except KeyboardInterrupt:
|
|
117
|
+
return True, "Proxy stopped by user"
|
|
118
|
+
else:
|
|
119
|
+
# Run in background
|
|
120
|
+
with open(LOG_FILE, "a") as log:
|
|
121
|
+
process = subprocess.Popen(
|
|
122
|
+
cmd,
|
|
123
|
+
env=env,
|
|
124
|
+
stdout=log,
|
|
125
|
+
stderr=subprocess.STDOUT,
|
|
126
|
+
start_new_session=True,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# Save PID
|
|
130
|
+
PID_FILE.write_text(str(process.pid))
|
|
131
|
+
|
|
132
|
+
# Wait a moment to check if it started successfully
|
|
133
|
+
time.sleep(1)
|
|
134
|
+
|
|
135
|
+
running, _ = is_proxy_running()
|
|
136
|
+
if running:
|
|
137
|
+
return True, f"Proxy started on port {port} (PID {process.pid})"
|
|
138
|
+
else:
|
|
139
|
+
return False, f"Proxy failed to start. Check {LOG_FILE} for details"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def stop_proxy() -> tuple[bool, str]:
|
|
143
|
+
"""
|
|
144
|
+
Stop the Tweek proxy server.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
Tuple of (success, message)
|
|
148
|
+
"""
|
|
149
|
+
running, pid = is_proxy_running()
|
|
150
|
+
|
|
151
|
+
if not running:
|
|
152
|
+
return False, "Proxy is not running"
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
os.kill(pid, signal.SIGTERM)
|
|
156
|
+
|
|
157
|
+
# Wait for graceful shutdown
|
|
158
|
+
for _ in range(10):
|
|
159
|
+
time.sleep(0.5)
|
|
160
|
+
try:
|
|
161
|
+
os.kill(pid, 0)
|
|
162
|
+
except ProcessLookupError:
|
|
163
|
+
break
|
|
164
|
+
else:
|
|
165
|
+
# Force kill if still running
|
|
166
|
+
os.kill(pid, signal.SIGKILL)
|
|
167
|
+
|
|
168
|
+
PID_FILE.unlink(missing_ok=True)
|
|
169
|
+
return True, f"Proxy stopped (PID {pid})"
|
|
170
|
+
|
|
171
|
+
except ProcessLookupError:
|
|
172
|
+
PID_FILE.unlink(missing_ok=True)
|
|
173
|
+
return True, "Proxy was already stopped"
|
|
174
|
+
except PermissionError:
|
|
175
|
+
return False, f"Permission denied stopping PID {pid}"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def get_proxy_info() -> dict:
|
|
179
|
+
"""Get detailed proxy status information."""
|
|
180
|
+
running, pid = is_proxy_running()
|
|
181
|
+
|
|
182
|
+
info = {
|
|
183
|
+
"running": running,
|
|
184
|
+
"pid": pid,
|
|
185
|
+
"pid_file": str(PID_FILE),
|
|
186
|
+
"log_file": str(LOG_FILE),
|
|
187
|
+
"ca_dir": str(CA_DIR),
|
|
188
|
+
"ca_cert": str(CA_DIR / "mitmproxy-ca-cert.pem"),
|
|
189
|
+
"default_port": DEFAULT_PORT,
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
# Check if CA cert exists
|
|
193
|
+
ca_cert = CA_DIR / "mitmproxy-ca-cert.pem"
|
|
194
|
+
info["ca_cert_exists"] = ca_cert.exists()
|
|
195
|
+
|
|
196
|
+
return info
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def install_ca_certificate() -> tuple[bool, str]:
|
|
200
|
+
"""
|
|
201
|
+
Install the Tweek proxy CA certificate in the system trust store.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
Tuple of (success, message)
|
|
205
|
+
"""
|
|
206
|
+
import platform
|
|
207
|
+
|
|
208
|
+
ca_cert = CA_DIR / "mitmproxy-ca-cert.pem"
|
|
209
|
+
|
|
210
|
+
if not ca_cert.exists():
|
|
211
|
+
# Generate CA cert by starting proxy briefly
|
|
212
|
+
ensure_directories()
|
|
213
|
+
try:
|
|
214
|
+
import mitmproxy
|
|
215
|
+
from mitmproxy.certs import CertStore
|
|
216
|
+
|
|
217
|
+
# This will generate the CA if it doesn't exist
|
|
218
|
+
store = CertStore.from_store(str(CA_DIR), "mitmproxy", 2048)
|
|
219
|
+
# CA is now generated
|
|
220
|
+
except Exception as e:
|
|
221
|
+
return False, f"Failed to generate CA certificate: {e}"
|
|
222
|
+
|
|
223
|
+
if not ca_cert.exists():
|
|
224
|
+
return False, "CA certificate not found. Start the proxy first to generate it."
|
|
225
|
+
|
|
226
|
+
system = platform.system()
|
|
227
|
+
|
|
228
|
+
if system == "Darwin":
|
|
229
|
+
# macOS: Add to System Keychain
|
|
230
|
+
cmd = [
|
|
231
|
+
"sudo", "security", "add-trusted-cert",
|
|
232
|
+
"-d", "-r", "trustRoot",
|
|
233
|
+
"-k", "/Library/Keychains/System.keychain",
|
|
234
|
+
str(ca_cert)
|
|
235
|
+
]
|
|
236
|
+
try:
|
|
237
|
+
subprocess.run(cmd, check=True)
|
|
238
|
+
return True, "CA certificate installed in macOS System Keychain"
|
|
239
|
+
except subprocess.CalledProcessError as e:
|
|
240
|
+
return False, f"Failed to install CA certificate: {e}"
|
|
241
|
+
|
|
242
|
+
elif system == "Linux":
|
|
243
|
+
# Linux: Copy to /usr/local/share/ca-certificates
|
|
244
|
+
dest = Path("/usr/local/share/ca-certificates/tweek-proxy.crt")
|
|
245
|
+
try:
|
|
246
|
+
subprocess.run(["sudo", "cp", str(ca_cert), str(dest)], check=True)
|
|
247
|
+
subprocess.run(["sudo", "update-ca-certificates"], check=True)
|
|
248
|
+
return True, "CA certificate installed in system trust store"
|
|
249
|
+
except subprocess.CalledProcessError as e:
|
|
250
|
+
return False, f"Failed to install CA certificate: {e}"
|
|
251
|
+
|
|
252
|
+
else:
|
|
253
|
+
return False, f"Automatic CA installation not supported on {system}. Please install manually: {ca_cert}"
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def get_proxy_env_vars(port: int = DEFAULT_PORT) -> dict[str, str]:
|
|
257
|
+
"""
|
|
258
|
+
Get environment variables to configure applications to use the proxy.
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
Dictionary of environment variables
|
|
262
|
+
"""
|
|
263
|
+
proxy_url = f"http://127.0.0.1:{port}"
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
"HTTP_PROXY": proxy_url,
|
|
267
|
+
"HTTPS_PROXY": proxy_url,
|
|
268
|
+
"http_proxy": proxy_url,
|
|
269
|
+
"https_proxy": proxy_url,
|
|
270
|
+
# For Node.js apps that don't respect standard env vars
|
|
271
|
+
"NODE_TLS_REJECT_UNAUTHORIZED": "0", # Required for self-signed CA
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def generate_wrapper_script(
|
|
276
|
+
app_command: str,
|
|
277
|
+
port: int = DEFAULT_PORT,
|
|
278
|
+
output_path: Optional[Path] = None,
|
|
279
|
+
) -> str:
|
|
280
|
+
"""
|
|
281
|
+
Generate a wrapper script to run an application through the Tweek proxy.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
app_command: The command to wrap (e.g., "npm start")
|
|
285
|
+
port: Proxy port
|
|
286
|
+
output_path: If provided, write script to this path
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
The wrapper script content
|
|
290
|
+
"""
|
|
291
|
+
env_vars = get_proxy_env_vars(port)
|
|
292
|
+
|
|
293
|
+
script = f"""#!/bin/bash
|
|
294
|
+
# Tweek Proxy Wrapper Script
|
|
295
|
+
# This script runs an application through the Tweek security proxy.
|
|
296
|
+
|
|
297
|
+
# Ensure Tweek proxy is running
|
|
298
|
+
if ! pgrep -f "tweek.*proxy" > /dev/null; then
|
|
299
|
+
echo "Starting Tweek proxy..."
|
|
300
|
+
tweek proxy start
|
|
301
|
+
sleep 2
|
|
302
|
+
fi
|
|
303
|
+
|
|
304
|
+
# Set proxy environment variables
|
|
305
|
+
{chr(10).join(f'export {k}="{v}"' for k, v in env_vars.items())}
|
|
306
|
+
|
|
307
|
+
# Run the application
|
|
308
|
+
{app_command}
|
|
309
|
+
"""
|
|
310
|
+
|
|
311
|
+
if output_path:
|
|
312
|
+
output_path.write_text(script)
|
|
313
|
+
output_path.chmod(0o755)
|
|
314
|
+
|
|
315
|
+
return script
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tweek Sandbox - Cross-platform command sandboxing.
|
|
3
|
+
|
|
4
|
+
Provides isolated execution environments:
|
|
5
|
+
- macOS: sandbox-exec (built-in)
|
|
6
|
+
- Linux: firejail (optional, recommended) or bubblewrap
|
|
7
|
+
- Windows: Not available
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from tweek.platform import PLATFORM, Platform, IS_MACOS, IS_LINUX, get_sandbox_tool
|
|
11
|
+
|
|
12
|
+
# Import platform-specific implementations
|
|
13
|
+
SANDBOX_AVAILABLE = False
|
|
14
|
+
SANDBOX_TOOL = None
|
|
15
|
+
|
|
16
|
+
if IS_MACOS:
|
|
17
|
+
try:
|
|
18
|
+
from .profile_generator import ProfileGenerator, SkillManifest
|
|
19
|
+
from .executor import SandboxExecutor, ExecutionResult
|
|
20
|
+
SANDBOX_AVAILABLE = True
|
|
21
|
+
SANDBOX_TOOL = "sandbox-exec"
|
|
22
|
+
except ImportError:
|
|
23
|
+
ProfileGenerator = None
|
|
24
|
+
SkillManifest = None
|
|
25
|
+
SandboxExecutor = None
|
|
26
|
+
ExecutionResult = None
|
|
27
|
+
|
|
28
|
+
elif IS_LINUX:
|
|
29
|
+
try:
|
|
30
|
+
from .linux import LinuxSandbox, prompt_install_firejail, get_sandbox
|
|
31
|
+
_sandbox = get_sandbox()
|
|
32
|
+
if _sandbox and _sandbox.available:
|
|
33
|
+
SANDBOX_AVAILABLE = True
|
|
34
|
+
SANDBOX_TOOL = _sandbox.tool
|
|
35
|
+
except ImportError:
|
|
36
|
+
LinuxSandbox = None
|
|
37
|
+
prompt_install_firejail = None
|
|
38
|
+
get_sandbox = None
|
|
39
|
+
|
|
40
|
+
# Keep macOS imports available for backwards compatibility
|
|
41
|
+
try:
|
|
42
|
+
from .profile_generator import ProfileGenerator, SkillManifest
|
|
43
|
+
from .executor import SandboxExecutor, ExecutionResult
|
|
44
|
+
except ImportError:
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_sandbox_status() -> dict:
|
|
49
|
+
"""Get sandbox availability status for current platform."""
|
|
50
|
+
tool = get_sandbox_tool()
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
"available": SANDBOX_AVAILABLE,
|
|
54
|
+
"tool": SANDBOX_TOOL or tool,
|
|
55
|
+
"platform": PLATFORM.value,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
__all__ = [
|
|
60
|
+
"ProfileGenerator",
|
|
61
|
+
"SkillManifest",
|
|
62
|
+
"SandboxExecutor",
|
|
63
|
+
"ExecutionResult",
|
|
64
|
+
"SANDBOX_AVAILABLE",
|
|
65
|
+
"SANDBOX_TOOL",
|
|
66
|
+
"get_sandbox_status",
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
# Add Linux-specific exports if available
|
|
70
|
+
if IS_LINUX:
|
|
71
|
+
__all__.extend(["LinuxSandbox", "prompt_install_firejail", "get_sandbox"])
|