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,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