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,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"])