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,382 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Tweek Sandbox Executor
|
|
4
|
+
|
|
5
|
+
Executes commands in a macOS sandbox for preview/speculative execution.
|
|
6
|
+
Captures file accesses, network attempts, and process spawns.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
executor = SandboxExecutor()
|
|
10
|
+
result = executor.preview_command("curl http://evil.com", skill="my-skill")
|
|
11
|
+
|
|
12
|
+
if result.suspicious:
|
|
13
|
+
print("Blocked:", result.violations)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import subprocess
|
|
18
|
+
import tempfile
|
|
19
|
+
import json
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import List, Optional, Dict, Any
|
|
23
|
+
import re
|
|
24
|
+
|
|
25
|
+
from .profile_generator import ProfileGenerator, SkillManifest
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class ExecutionResult:
|
|
30
|
+
"""Result of a sandboxed command execution."""
|
|
31
|
+
|
|
32
|
+
# Basic execution info
|
|
33
|
+
exit_code: int
|
|
34
|
+
stdout: str
|
|
35
|
+
stderr: str
|
|
36
|
+
timed_out: bool = False
|
|
37
|
+
|
|
38
|
+
# Security analysis
|
|
39
|
+
suspicious: bool = False
|
|
40
|
+
violations: List[str] = field(default_factory=list)
|
|
41
|
+
|
|
42
|
+
# Captured access attempts
|
|
43
|
+
file_reads: List[str] = field(default_factory=list)
|
|
44
|
+
file_writes: List[str] = field(default_factory=list)
|
|
45
|
+
network_attempts: List[str] = field(default_factory=list)
|
|
46
|
+
process_spawns: List[str] = field(default_factory=list)
|
|
47
|
+
|
|
48
|
+
# Denied operations (blocked by sandbox)
|
|
49
|
+
denied_operations: List[str] = field(default_factory=list)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class SandboxExecutor:
|
|
53
|
+
"""Executes commands in a sandbox and analyzes their behavior."""
|
|
54
|
+
|
|
55
|
+
# Sensitive paths that trigger suspicion
|
|
56
|
+
SENSITIVE_PATHS = [
|
|
57
|
+
r"\.ssh",
|
|
58
|
+
r"\.aws",
|
|
59
|
+
r"\.gnupg",
|
|
60
|
+
r"\.netrc",
|
|
61
|
+
r"\.env",
|
|
62
|
+
r"credentials",
|
|
63
|
+
r"\.kube/config",
|
|
64
|
+
r"\.config/gcloud",
|
|
65
|
+
r"keychain",
|
|
66
|
+
r"Cookies",
|
|
67
|
+
r"Login Data",
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
# Suspicious network patterns
|
|
71
|
+
SUSPICIOUS_HOSTS = [
|
|
72
|
+
r"pastebin\.com",
|
|
73
|
+
r"hastebin\.com",
|
|
74
|
+
r"ghostbin\.",
|
|
75
|
+
r"0x0\.st",
|
|
76
|
+
r"transfer\.sh",
|
|
77
|
+
r"file\.io",
|
|
78
|
+
r"webhook\.site",
|
|
79
|
+
r"requestbin\.",
|
|
80
|
+
r"ngrok\.io",
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
def __init__(self, profiles_dir: Optional[Path] = None):
|
|
84
|
+
"""Initialize the executor."""
|
|
85
|
+
self.generator = ProfileGenerator(profiles_dir=profiles_dir)
|
|
86
|
+
self._check_sandbox_available()
|
|
87
|
+
|
|
88
|
+
def _check_sandbox_available(self) -> bool:
|
|
89
|
+
"""Check if sandbox-exec is available."""
|
|
90
|
+
return Path("/usr/bin/sandbox-exec").exists()
|
|
91
|
+
|
|
92
|
+
def preview_command(
|
|
93
|
+
self,
|
|
94
|
+
command: str,
|
|
95
|
+
skill: str = "preview",
|
|
96
|
+
timeout: float = 5.0,
|
|
97
|
+
env: Optional[Dict[str, str]] = None,
|
|
98
|
+
cwd: Optional[Path] = None,
|
|
99
|
+
) -> ExecutionResult:
|
|
100
|
+
"""
|
|
101
|
+
Execute a command in a restrictive sandbox for preview.
|
|
102
|
+
|
|
103
|
+
This runs the command with limited permissions to see what it
|
|
104
|
+
TRIES to do, without actually allowing dangerous operations.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
command: Shell command to execute
|
|
108
|
+
skill: Skill name for profile (uses restrictive preview profile)
|
|
109
|
+
timeout: Max execution time in seconds
|
|
110
|
+
env: Additional environment variables
|
|
111
|
+
cwd: Working directory
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
ExecutionResult with captured behavior
|
|
115
|
+
"""
|
|
116
|
+
# Log the start of preview
|
|
117
|
+
try:
|
|
118
|
+
from tweek.logging.security_log import get_logger, SecurityEvent, EventType
|
|
119
|
+
get_logger().log(SecurityEvent(
|
|
120
|
+
event_type=EventType.SANDBOX_PREVIEW,
|
|
121
|
+
tool_name="sandbox_executor",
|
|
122
|
+
decision="allow",
|
|
123
|
+
metadata={
|
|
124
|
+
"command": command,
|
|
125
|
+
"skill": skill,
|
|
126
|
+
"timeout": timeout,
|
|
127
|
+
},
|
|
128
|
+
source="sandbox",
|
|
129
|
+
))
|
|
130
|
+
except Exception:
|
|
131
|
+
pass
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
# Create a restrictive preview manifest
|
|
135
|
+
# Must allow enough for basic shell operations
|
|
136
|
+
manifest = SkillManifest(
|
|
137
|
+
name=f"preview-{skill}",
|
|
138
|
+
read_paths=[
|
|
139
|
+
"./",
|
|
140
|
+
"/usr/lib",
|
|
141
|
+
"/usr/local/lib",
|
|
142
|
+
"/System",
|
|
143
|
+
"/bin",
|
|
144
|
+
"/usr/bin",
|
|
145
|
+
"/private/var/db",
|
|
146
|
+
"/dev",
|
|
147
|
+
"/Library/Preferences",
|
|
148
|
+
"/var/folders", # Temp files
|
|
149
|
+
],
|
|
150
|
+
write_paths=[
|
|
151
|
+
"/dev/null",
|
|
152
|
+
"/dev/stdout",
|
|
153
|
+
"/dev/stderr",
|
|
154
|
+
"/private/var/folders", # Temp files
|
|
155
|
+
],
|
|
156
|
+
deny_paths=[
|
|
157
|
+
"~/.ssh", "~/.aws", "~/.gnupg", "~/.netrc",
|
|
158
|
+
"~/.env", "**/.env", "~/.kube", "~/.config/gcloud",
|
|
159
|
+
],
|
|
160
|
+
network_deny_all=True,
|
|
161
|
+
allow_subprocess=True, # Needed for basic shell operations
|
|
162
|
+
allow_exec=["/bin/bash", "/bin/sh", "/usr/bin/env", "/bin/echo"],
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
# Generate and save the profile
|
|
166
|
+
profile_path = self.generator.save(manifest)
|
|
167
|
+
|
|
168
|
+
# Build the sandboxed command
|
|
169
|
+
sandboxed_cmd = f'sandbox-exec -f "{profile_path}" /bin/bash -c {self._shell_quote(command)}'
|
|
170
|
+
|
|
171
|
+
# Set up environment
|
|
172
|
+
run_env = os.environ.copy()
|
|
173
|
+
if env:
|
|
174
|
+
run_env.update(env)
|
|
175
|
+
|
|
176
|
+
# Execute with timeout
|
|
177
|
+
result = ExecutionResult(exit_code=-1, stdout="", stderr="")
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
proc = subprocess.run(
|
|
181
|
+
sandboxed_cmd,
|
|
182
|
+
shell=True,
|
|
183
|
+
capture_output=True,
|
|
184
|
+
text=True,
|
|
185
|
+
timeout=timeout,
|
|
186
|
+
cwd=cwd,
|
|
187
|
+
env=run_env,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
result.exit_code = proc.returncode
|
|
191
|
+
result.stdout = proc.stdout
|
|
192
|
+
result.stderr = proc.stderr
|
|
193
|
+
|
|
194
|
+
except subprocess.TimeoutExpired:
|
|
195
|
+
result.timed_out = True
|
|
196
|
+
result.violations.append(f"Command timed out after {timeout}s")
|
|
197
|
+
result.suspicious = True
|
|
198
|
+
|
|
199
|
+
except Exception as e:
|
|
200
|
+
result.stderr = str(e)
|
|
201
|
+
result.exit_code = -1
|
|
202
|
+
|
|
203
|
+
# Analyze the output for sandbox violations
|
|
204
|
+
self._analyze_sandbox_output(result, command)
|
|
205
|
+
|
|
206
|
+
# Log violations if detected
|
|
207
|
+
if result.suspicious:
|
|
208
|
+
try:
|
|
209
|
+
from tweek.logging.security_log import get_logger, SecurityEvent, EventType
|
|
210
|
+
get_logger().log(SecurityEvent(
|
|
211
|
+
event_type=EventType.SANDBOX_PREVIEW,
|
|
212
|
+
tool_name="sandbox_executor",
|
|
213
|
+
decision="block",
|
|
214
|
+
metadata={
|
|
215
|
+
"command": command,
|
|
216
|
+
"skill": skill,
|
|
217
|
+
"violations": result.violations,
|
|
218
|
+
},
|
|
219
|
+
source="sandbox",
|
|
220
|
+
))
|
|
221
|
+
except Exception:
|
|
222
|
+
pass
|
|
223
|
+
|
|
224
|
+
# Clean up preview profile
|
|
225
|
+
self.generator.delete_profile(f"preview-{skill}")
|
|
226
|
+
|
|
227
|
+
return result
|
|
228
|
+
|
|
229
|
+
except Exception as exc:
|
|
230
|
+
# Log unexpected errors - never break the original operation
|
|
231
|
+
try:
|
|
232
|
+
from tweek.logging.security_log import get_logger, SecurityEvent, EventType
|
|
233
|
+
get_logger().log(SecurityEvent(
|
|
234
|
+
event_type=EventType.ERROR,
|
|
235
|
+
tool_name="sandbox_executor",
|
|
236
|
+
decision="error",
|
|
237
|
+
metadata={
|
|
238
|
+
"command": command,
|
|
239
|
+
"skill": skill,
|
|
240
|
+
"error": str(exc),
|
|
241
|
+
"error_type": type(exc).__name__,
|
|
242
|
+
},
|
|
243
|
+
source="sandbox",
|
|
244
|
+
))
|
|
245
|
+
except Exception:
|
|
246
|
+
pass
|
|
247
|
+
raise
|
|
248
|
+
|
|
249
|
+
def _shell_quote(self, s: str) -> str:
|
|
250
|
+
"""Quote a string for shell use."""
|
|
251
|
+
return "'" + s.replace("'", "'\"'\"'") + "'"
|
|
252
|
+
|
|
253
|
+
def _analyze_sandbox_output(self, result: ExecutionResult, command: str) -> None:
|
|
254
|
+
"""Analyze execution results for suspicious behavior."""
|
|
255
|
+
|
|
256
|
+
# Check stderr for sandbox denials
|
|
257
|
+
denial_pattern = r"sandbox-exec: .* deny"
|
|
258
|
+
denials = re.findall(denial_pattern, result.stderr, re.IGNORECASE)
|
|
259
|
+
result.denied_operations.extend(denials)
|
|
260
|
+
|
|
261
|
+
# Check for sensitive path access attempts in command
|
|
262
|
+
for pattern in self.SENSITIVE_PATHS:
|
|
263
|
+
if re.search(pattern, command, re.IGNORECASE):
|
|
264
|
+
result.violations.append(f"Attempts to access sensitive path: {pattern}")
|
|
265
|
+
result.suspicious = True
|
|
266
|
+
|
|
267
|
+
# Check for suspicious network destinations in command
|
|
268
|
+
for pattern in self.SUSPICIOUS_HOSTS:
|
|
269
|
+
if re.search(pattern, command, re.IGNORECASE):
|
|
270
|
+
result.violations.append(f"Attempts to contact suspicious host: {pattern}")
|
|
271
|
+
result.suspicious = True
|
|
272
|
+
|
|
273
|
+
# Check for data exfiltration patterns
|
|
274
|
+
exfil_patterns = [
|
|
275
|
+
(r"curl.*-d.*\$\(", "Potential data exfiltration via curl POST"),
|
|
276
|
+
(r"wget.*--post-data", "Potential data exfiltration via wget POST"),
|
|
277
|
+
(r"\| *nc ", "Piping data to netcat"),
|
|
278
|
+
(r"\| *curl", "Piping data to curl"),
|
|
279
|
+
(r"base64.*\|.*curl", "Base64 encoding and sending data"),
|
|
280
|
+
]
|
|
281
|
+
|
|
282
|
+
for pattern, description in exfil_patterns:
|
|
283
|
+
if re.search(pattern, command, re.IGNORECASE):
|
|
284
|
+
result.violations.append(description)
|
|
285
|
+
result.suspicious = True
|
|
286
|
+
|
|
287
|
+
# Mark as suspicious if sandbox blocked operations
|
|
288
|
+
if result.denied_operations:
|
|
289
|
+
result.suspicious = True
|
|
290
|
+
result.violations.append(f"Sandbox blocked {len(result.denied_operations)} operations")
|
|
291
|
+
|
|
292
|
+
def execute_sandboxed(
|
|
293
|
+
self,
|
|
294
|
+
command: str,
|
|
295
|
+
skill: str,
|
|
296
|
+
manifest: Optional[SkillManifest] = None,
|
|
297
|
+
timeout: float = 30.0,
|
|
298
|
+
env: Optional[Dict[str, str]] = None,
|
|
299
|
+
cwd: Optional[Path] = None,
|
|
300
|
+
) -> ExecutionResult:
|
|
301
|
+
"""
|
|
302
|
+
Execute a command in a sandbox with skill-specific permissions.
|
|
303
|
+
|
|
304
|
+
Unlike preview_command, this uses the skill's actual manifest
|
|
305
|
+
permissions and allows the command to run with appropriate access.
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
command: Shell command to execute
|
|
309
|
+
skill: Skill name for profile lookup
|
|
310
|
+
manifest: Optional manifest (uses existing or default if not provided)
|
|
311
|
+
timeout: Max execution time in seconds
|
|
312
|
+
env: Additional environment variables
|
|
313
|
+
cwd: Working directory
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
ExecutionResult with execution details
|
|
317
|
+
"""
|
|
318
|
+
# Get or create profile
|
|
319
|
+
profile_path = self.generator.get_profile_path(skill)
|
|
320
|
+
|
|
321
|
+
if profile_path is None:
|
|
322
|
+
if manifest:
|
|
323
|
+
profile_path = self.generator.save(manifest)
|
|
324
|
+
else:
|
|
325
|
+
# Use default restrictive profile
|
|
326
|
+
manifest = SkillManifest.default(skill)
|
|
327
|
+
profile_path = self.generator.save(manifest)
|
|
328
|
+
|
|
329
|
+
# Build sandboxed command
|
|
330
|
+
sandboxed_cmd = f'sandbox-exec -f "{profile_path}" /bin/bash -c {self._shell_quote(command)}'
|
|
331
|
+
|
|
332
|
+
# Set up environment
|
|
333
|
+
run_env = os.environ.copy()
|
|
334
|
+
if env:
|
|
335
|
+
run_env.update(env)
|
|
336
|
+
|
|
337
|
+
# Execute
|
|
338
|
+
result = ExecutionResult(exit_code=-1, stdout="", stderr="")
|
|
339
|
+
|
|
340
|
+
try:
|
|
341
|
+
proc = subprocess.run(
|
|
342
|
+
sandboxed_cmd,
|
|
343
|
+
shell=True,
|
|
344
|
+
capture_output=True,
|
|
345
|
+
text=True,
|
|
346
|
+
timeout=timeout,
|
|
347
|
+
cwd=cwd,
|
|
348
|
+
env=run_env,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
result.exit_code = proc.returncode
|
|
352
|
+
result.stdout = proc.stdout
|
|
353
|
+
result.stderr = proc.stderr
|
|
354
|
+
|
|
355
|
+
except subprocess.TimeoutExpired:
|
|
356
|
+
result.timed_out = True
|
|
357
|
+
result.violations.append(f"Command timed out after {timeout}s")
|
|
358
|
+
|
|
359
|
+
except Exception as e:
|
|
360
|
+
result.stderr = str(e)
|
|
361
|
+
result.exit_code = -1
|
|
362
|
+
|
|
363
|
+
# Analyze output
|
|
364
|
+
self._analyze_sandbox_output(result, command)
|
|
365
|
+
|
|
366
|
+
return result
|
|
367
|
+
|
|
368
|
+
def get_sandbox_command(self, command: str, skill: str) -> str:
|
|
369
|
+
"""
|
|
370
|
+
Get the sandbox-wrapped version of a command.
|
|
371
|
+
|
|
372
|
+
This doesn't execute anything, just returns what the
|
|
373
|
+
sandboxed command would look like.
|
|
374
|
+
|
|
375
|
+
Args:
|
|
376
|
+
command: Original command
|
|
377
|
+
skill: Skill name
|
|
378
|
+
|
|
379
|
+
Returns:
|
|
380
|
+
Sandbox-wrapped command string
|
|
381
|
+
"""
|
|
382
|
+
return self.generator.wrap_command(command, skill)
|
tweek/sandbox/linux.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Linux sandbox implementation using firejail.
|
|
3
|
+
|
|
4
|
+
Firejail is a SUID sandbox program that uses Linux namespaces,
|
|
5
|
+
seccomp-bpf, and capabilities to restrict process execution.
|
|
6
|
+
|
|
7
|
+
If firejail is not available, falls back to bubblewrap (bwrap)
|
|
8
|
+
which is often installed with Flatpak.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
from tweek.platform import IS_LINUX, get_linux_package_manager
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class SandboxResult:
|
|
21
|
+
"""Result of a sandboxed command execution."""
|
|
22
|
+
success: bool
|
|
23
|
+
exit_code: int
|
|
24
|
+
stdout: str
|
|
25
|
+
stderr: str
|
|
26
|
+
blocked_actions: list[str]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class LinuxSandbox:
|
|
30
|
+
"""
|
|
31
|
+
Linux sandbox using firejail or bubblewrap.
|
|
32
|
+
|
|
33
|
+
Provides similar functionality to macOS sandbox-exec:
|
|
34
|
+
- Restrict network access
|
|
35
|
+
- Restrict filesystem access
|
|
36
|
+
- Restrict process capabilities
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self):
|
|
40
|
+
self.tool = self._detect_tool()
|
|
41
|
+
|
|
42
|
+
def _detect_tool(self) -> Optional[str]:
|
|
43
|
+
"""Detect available sandbox tool."""
|
|
44
|
+
if shutil.which("firejail"):
|
|
45
|
+
return "firejail"
|
|
46
|
+
elif shutil.which("bwrap"):
|
|
47
|
+
return "bubblewrap"
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def available(self) -> bool:
|
|
52
|
+
"""Check if sandbox is available."""
|
|
53
|
+
return self.tool is not None
|
|
54
|
+
|
|
55
|
+
def get_install_command(self) -> Optional[str]:
|
|
56
|
+
"""Get command to install firejail."""
|
|
57
|
+
pkg_info = get_linux_package_manager()
|
|
58
|
+
if pkg_info:
|
|
59
|
+
_, command = pkg_info
|
|
60
|
+
return " ".join(command)
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
def _build_firejail_command(
|
|
64
|
+
self,
|
|
65
|
+
command: str,
|
|
66
|
+
allow_network: bool = False,
|
|
67
|
+
allow_write: bool = False,
|
|
68
|
+
timeout: int = 30
|
|
69
|
+
) -> list[str]:
|
|
70
|
+
"""Build firejail command with restrictions."""
|
|
71
|
+
args = [
|
|
72
|
+
"firejail",
|
|
73
|
+
"--noprofile", # Don't use app-specific profile
|
|
74
|
+
"--quiet", # Reduce output noise
|
|
75
|
+
"--caps.drop=all", # Drop all capabilities
|
|
76
|
+
"--noroot", # No root privileges
|
|
77
|
+
"--seccomp", # Enable seccomp filters
|
|
78
|
+
"--private-tmp", # Isolated /tmp
|
|
79
|
+
"--nogroups", # No supplementary groups
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
if not allow_network:
|
|
83
|
+
args.append("--net=none")
|
|
84
|
+
|
|
85
|
+
if not allow_write:
|
|
86
|
+
args.extend([
|
|
87
|
+
"--read-only=/",
|
|
88
|
+
"--read-write=/tmp",
|
|
89
|
+
"--read-write=/dev/null",
|
|
90
|
+
"--read-write=/dev/zero",
|
|
91
|
+
])
|
|
92
|
+
|
|
93
|
+
args.extend([
|
|
94
|
+
f"--timeout={timeout}",
|
|
95
|
+
"bash", "-c", command
|
|
96
|
+
])
|
|
97
|
+
|
|
98
|
+
return args
|
|
99
|
+
|
|
100
|
+
def _build_bubblewrap_command(
|
|
101
|
+
self,
|
|
102
|
+
command: str,
|
|
103
|
+
allow_network: bool = False,
|
|
104
|
+
allow_write: bool = False,
|
|
105
|
+
timeout: int = 30
|
|
106
|
+
) -> list[str]:
|
|
107
|
+
"""Build bubblewrap command with restrictions."""
|
|
108
|
+
args = [
|
|
109
|
+
"bwrap",
|
|
110
|
+
"--ro-bind", "/", "/", # Read-only root
|
|
111
|
+
"--dev", "/dev", # Minimal /dev
|
|
112
|
+
"--proc", "/proc", # /proc filesystem
|
|
113
|
+
"--tmpfs", "/tmp", # Isolated /tmp
|
|
114
|
+
"--unshare-all", # Unshare all namespaces
|
|
115
|
+
"--die-with-parent", # Clean up on parent exit
|
|
116
|
+
"--new-session", # New session
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
if not allow_network:
|
|
120
|
+
args.append("--unshare-net")
|
|
121
|
+
|
|
122
|
+
if allow_write:
|
|
123
|
+
# Re-bind specific paths as read-write
|
|
124
|
+
args.extend(["--bind", "/tmp", "/tmp"])
|
|
125
|
+
|
|
126
|
+
args.extend(["bash", "-c", command])
|
|
127
|
+
|
|
128
|
+
# Wrap with timeout
|
|
129
|
+
return ["timeout", str(timeout)] + args
|
|
130
|
+
|
|
131
|
+
def preview_command(
|
|
132
|
+
self,
|
|
133
|
+
command: str,
|
|
134
|
+
allow_network: bool = False,
|
|
135
|
+
allow_write: bool = False,
|
|
136
|
+
timeout: int = 10
|
|
137
|
+
) -> SandboxResult:
|
|
138
|
+
"""
|
|
139
|
+
Run a command in the sandbox and capture what it tries to do.
|
|
140
|
+
|
|
141
|
+
This is used for "preview" mode where we want to see what
|
|
142
|
+
a command would do before allowing it.
|
|
143
|
+
"""
|
|
144
|
+
if not self.available:
|
|
145
|
+
return SandboxResult(
|
|
146
|
+
success=False,
|
|
147
|
+
exit_code=-1,
|
|
148
|
+
stdout="",
|
|
149
|
+
stderr="Sandbox not available",
|
|
150
|
+
blocked_actions=[]
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
if self.tool == "firejail":
|
|
154
|
+
sandbox_cmd = self._build_firejail_command(
|
|
155
|
+
command, allow_network, allow_write, timeout
|
|
156
|
+
)
|
|
157
|
+
else:
|
|
158
|
+
sandbox_cmd = self._build_bubblewrap_command(
|
|
159
|
+
command, allow_network, allow_write, timeout
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
result = subprocess.run(
|
|
164
|
+
sandbox_cmd,
|
|
165
|
+
capture_output=True,
|
|
166
|
+
text=True,
|
|
167
|
+
timeout=timeout + 5 # Extra buffer
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
blocked = self._parse_blocked_actions(result.stderr)
|
|
171
|
+
|
|
172
|
+
return SandboxResult(
|
|
173
|
+
success=result.returncode == 0,
|
|
174
|
+
exit_code=result.returncode,
|
|
175
|
+
stdout=result.stdout,
|
|
176
|
+
stderr=result.stderr,
|
|
177
|
+
blocked_actions=blocked
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
except subprocess.TimeoutExpired:
|
|
181
|
+
return SandboxResult(
|
|
182
|
+
success=False,
|
|
183
|
+
exit_code=-1,
|
|
184
|
+
stdout="",
|
|
185
|
+
stderr="Command timed out",
|
|
186
|
+
blocked_actions=["timeout"]
|
|
187
|
+
)
|
|
188
|
+
except Exception as e:
|
|
189
|
+
return SandboxResult(
|
|
190
|
+
success=False,
|
|
191
|
+
exit_code=-1,
|
|
192
|
+
stdout="",
|
|
193
|
+
stderr=str(e),
|
|
194
|
+
blocked_actions=[]
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
def _parse_blocked_actions(self, stderr: str) -> list[str]:
|
|
198
|
+
"""Parse sandbox output to find blocked actions."""
|
|
199
|
+
blocked = []
|
|
200
|
+
|
|
201
|
+
# Firejail patterns
|
|
202
|
+
if "Permission denied" in stderr:
|
|
203
|
+
blocked.append("permission_denied")
|
|
204
|
+
if "Network is disabled" in stderr or "No network" in stderr:
|
|
205
|
+
blocked.append("network_blocked")
|
|
206
|
+
if "Read-only file system" in stderr:
|
|
207
|
+
blocked.append("write_blocked")
|
|
208
|
+
if "Operation not permitted" in stderr:
|
|
209
|
+
blocked.append("operation_blocked")
|
|
210
|
+
|
|
211
|
+
return blocked
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def prompt_install_firejail(console) -> bool:
|
|
215
|
+
"""
|
|
216
|
+
Prompt user to install firejail for sandbox support.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
console: Rich console for output
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
True if firejail is now available, False otherwise
|
|
223
|
+
"""
|
|
224
|
+
from rich.prompt import Confirm
|
|
225
|
+
|
|
226
|
+
if not IS_LINUX:
|
|
227
|
+
return False
|
|
228
|
+
|
|
229
|
+
if shutil.which("firejail"):
|
|
230
|
+
return True # Already installed
|
|
231
|
+
|
|
232
|
+
console.print("\n[yellow]Sandbox not available[/yellow]")
|
|
233
|
+
console.print("Firejail provides sandbox preview for dangerous commands.")
|
|
234
|
+
console.print("Without it, Tweek still provides 4/5 defense layers.\n")
|
|
235
|
+
|
|
236
|
+
pkg_info = get_linux_package_manager()
|
|
237
|
+
|
|
238
|
+
if not pkg_info:
|
|
239
|
+
console.print("[dim]Could not detect package manager.[/dim]")
|
|
240
|
+
console.print("Install firejail manually: https://firejail.wordpress.com/download-2/")
|
|
241
|
+
return False
|
|
242
|
+
|
|
243
|
+
manager, command = pkg_info
|
|
244
|
+
console.print(f"[dim]Detected package manager: {manager}[/dim]")
|
|
245
|
+
console.print(f"[dim]Command: {' '.join(command)}[/dim]\n")
|
|
246
|
+
|
|
247
|
+
if Confirm.ask("Install firejail for full sandbox protection?", default=False):
|
|
248
|
+
try:
|
|
249
|
+
console.print("[cyan]Installing firejail...[/cyan]")
|
|
250
|
+
subprocess.run(command, check=True)
|
|
251
|
+
|
|
252
|
+
# Verify installation
|
|
253
|
+
if shutil.which("firejail"):
|
|
254
|
+
console.print("[green]Firejail installed successfully![/green]")
|
|
255
|
+
return True
|
|
256
|
+
else:
|
|
257
|
+
console.print("[red]Installation completed but firejail not found in PATH[/red]")
|
|
258
|
+
return False
|
|
259
|
+
|
|
260
|
+
except subprocess.CalledProcessError as e:
|
|
261
|
+
console.print(f"[red]Installation failed (exit code {e.returncode})[/red]")
|
|
262
|
+
console.print("[dim]Try running the install command manually with sudo[/dim]")
|
|
263
|
+
return False
|
|
264
|
+
except KeyboardInterrupt:
|
|
265
|
+
console.print("\n[yellow]Installation cancelled.[/yellow]")
|
|
266
|
+
return False
|
|
267
|
+
else:
|
|
268
|
+
console.print("[dim]Skipping firejail. Sandbox layer will be disabled.[/dim]")
|
|
269
|
+
return False
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def get_sandbox() -> Optional[LinuxSandbox]:
|
|
273
|
+
"""Get a Linux sandbox instance if available."""
|
|
274
|
+
if not IS_LINUX:
|
|
275
|
+
return None
|
|
276
|
+
|
|
277
|
+
sandbox = LinuxSandbox()
|
|
278
|
+
return sandbox if sandbox.available else None
|