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
tweek/mcp/screening.py ADDED
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Tweek MCP Screening - Shared screening logic for MCP gateway and proxy.
4
+
5
+ Extracts the screening pipeline from TweekMCPServer so it can be reused
6
+ by both the MCP gateway (which converts should_prompt -> blocked) and
7
+ the MCP proxy (which queues should_prompt for human approval).
8
+ """
9
+
10
+ import logging
11
+ from typing import Any, Dict
12
+
13
+ from tweek.screening.context import ScreeningContext
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ def run_mcp_screening(context: ScreeningContext) -> Dict[str, Any]:
19
+ """
20
+ Run the shared screening pipeline on a tool call.
21
+
22
+ Returns dict with:
23
+ allowed: bool - Whether execution is permitted
24
+ blocked: bool - Whether execution is hard-blocked
25
+ should_prompt: bool - Whether human confirmation is needed
26
+ reason: Optional[str]
27
+ findings: List[Dict]
28
+ """
29
+ try:
30
+ from tweek.hooks.pre_tool_use import (
31
+ TierManager,
32
+ PatternMatcher,
33
+ run_compliance_scans,
34
+ run_screening_plugins,
35
+ )
36
+ from tweek.logging.security_log import get_logger as get_sec_logger
37
+
38
+ sec_logger = get_sec_logger()
39
+
40
+ # Resolve tier
41
+ tier_mgr = TierManager()
42
+ effective_tier, escalation = tier_mgr.get_effective_tier(
43
+ context.tool_name, context.content
44
+ )
45
+ context.tier = effective_tier
46
+
47
+ # Run compliance scans on input
48
+ should_block, compliance_msg, compliance_findings = run_compliance_scans(
49
+ content=context.content,
50
+ direction="input",
51
+ logger=sec_logger,
52
+ session_id=context.session_id,
53
+ tool_name=context.tool_name,
54
+ )
55
+
56
+ if should_block:
57
+ return {
58
+ "allowed": False,
59
+ "blocked": True,
60
+ "should_prompt": False,
61
+ "reason": compliance_msg or "Blocked by compliance scan",
62
+ "findings": compliance_findings,
63
+ }
64
+
65
+ # Skip further screening for safe tier
66
+ if effective_tier == "safe":
67
+ return {
68
+ "allowed": True,
69
+ "blocked": False,
70
+ "should_prompt": False,
71
+ "reason": None,
72
+ "findings": [],
73
+ }
74
+
75
+ # Pattern matching
76
+ pattern_matcher = PatternMatcher()
77
+ match = pattern_matcher.check(context.content)
78
+
79
+ if match:
80
+ pattern_name = match.get("pattern", match.get("name", "unknown"))
81
+ return {
82
+ "allowed": False,
83
+ "blocked": True,
84
+ "should_prompt": False,
85
+ "reason": f"Blocked by pattern match: {pattern_name}",
86
+ "findings": [match],
87
+ }
88
+
89
+ # Run screening plugins
90
+ legacy_context = context.to_legacy_dict()
91
+ allowed, should_prompt, screen_msg, screen_findings = run_screening_plugins(
92
+ tool_name=context.tool_name,
93
+ content=context.content,
94
+ context=legacy_context,
95
+ logger=sec_logger,
96
+ )
97
+
98
+ if not allowed:
99
+ return {
100
+ "allowed": False,
101
+ "blocked": True,
102
+ "should_prompt": False,
103
+ "reason": screen_msg or "Blocked by screening plugin",
104
+ "findings": screen_findings,
105
+ }
106
+
107
+ if should_prompt:
108
+ return {
109
+ "allowed": True,
110
+ "blocked": False,
111
+ "should_prompt": True,
112
+ "reason": screen_msg or "Requires user confirmation",
113
+ "findings": screen_findings,
114
+ }
115
+
116
+ return {
117
+ "allowed": True,
118
+ "blocked": False,
119
+ "should_prompt": False,
120
+ "reason": None,
121
+ "findings": [],
122
+ }
123
+
124
+ except ImportError as e:
125
+ logger.warning(f"Screening modules not available: {e}")
126
+ # Fail open with warning if screening not available
127
+ return {
128
+ "allowed": True,
129
+ "blocked": False,
130
+ "should_prompt": False,
131
+ "reason": f"Warning: screening unavailable ({e})",
132
+ "findings": [],
133
+ }
134
+ except Exception as e:
135
+ logger.error(f"Screening error: {e}")
136
+ # Fail closed on unexpected errors
137
+ return {
138
+ "allowed": False,
139
+ "blocked": True,
140
+ "should_prompt": False,
141
+ "reason": f"Screening error: {e}",
142
+ "findings": [],
143
+ }
144
+
145
+
146
+ def run_output_scan(content: str) -> Dict[str, Any]:
147
+ """
148
+ Scan output content for leaked credentials or sensitive data.
149
+
150
+ Returns dict with:
151
+ blocked: bool
152
+ reason: Optional[str]
153
+ findings: List[Dict]
154
+ """
155
+ try:
156
+ from tweek.hooks.pre_tool_use import run_compliance_scans
157
+ from tweek.logging.security_log import get_logger as get_sec_logger
158
+
159
+ sec_logger = get_sec_logger()
160
+ should_block, msg, findings = run_compliance_scans(
161
+ content=content,
162
+ direction="output",
163
+ logger=sec_logger,
164
+ tool_name="mcp_output_scan",
165
+ )
166
+
167
+ if should_block:
168
+ return {"blocked": True, "reason": msg, "findings": findings}
169
+
170
+ except ImportError:
171
+ pass
172
+ except Exception as e:
173
+ logger.debug(f"Output scan error: {e}")
174
+
175
+ return {"blocked": False}
tweek/mcp/server.py ADDED
@@ -0,0 +1,317 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Tweek MCP Gateway Server
4
+
5
+ Minimal MCP server exposing only tools that add genuinely new capabilities
6
+ not available as built-in desktop client tools:
7
+ - tweek_vault: Secure keychain credential retrieval
8
+ - tweek_status: Security status and activity reporting
9
+
10
+ Desktop clients' built-in tools (Bash, Read, Write, etc.) cannot be
11
+ intercepted via MCP. For upstream MCP server interception, use the
12
+ proxy mode: tweek mcp proxy
13
+
14
+ Usage:
15
+ tweek mcp serve # stdio mode (desktop clients)
16
+ """
17
+
18
+ import json
19
+ import logging
20
+ import os
21
+ from typing import Any, Dict, Optional
22
+
23
+ try:
24
+ from mcp.server import Server
25
+ from mcp.server.stdio import stdio_server
26
+ from mcp.types import (
27
+ TextContent,
28
+ Tool,
29
+ )
30
+ MCP_AVAILABLE = True
31
+ except ImportError:
32
+ MCP_AVAILABLE = False
33
+
34
+ from tweek.screening.context import ScreeningContext
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+ # Version for MCP server identification
39
+ MCP_SERVER_VERSION = "0.2.0"
40
+
41
+
42
+ def _check_mcp_available():
43
+ """Raise RuntimeError if MCP SDK is not installed."""
44
+ if not MCP_AVAILABLE:
45
+ raise RuntimeError(
46
+ "MCP SDK not installed. Install with: pip install 'tweek[mcp]' "
47
+ "or pip install mcp"
48
+ )
49
+
50
+
51
+ class TweekMCPServer:
52
+ """
53
+ Tweek MCP Gateway.
54
+
55
+ Exposes vault and status tools via MCP. These are genuinely new
56
+ capabilities not available as built-in desktop client tools.
57
+
58
+ For intercepting upstream MCP server tool calls, use TweekMCPProxy
59
+ from tweek.mcp.proxy instead.
60
+ """
61
+
62
+ def __init__(self, config: Optional[Dict[str, Any]] = None):
63
+ _check_mcp_available()
64
+ self.config = config or {}
65
+ self.server = Server("tweek-security")
66
+ self._setup_handlers()
67
+ self._request_count = 0
68
+ self._blocked_count = 0
69
+
70
+ def _setup_handlers(self):
71
+ """Register MCP protocol handlers."""
72
+
73
+ @self.server.list_tools()
74
+ async def list_tools() -> list[Tool]:
75
+ """Return the list of tools this server provides."""
76
+ tools = []
77
+
78
+ tool_configs = self.config.get("mcp", {}).get("gateway", {}).get("tools", {})
79
+
80
+ if tool_configs.get("vault", True):
81
+ tools.append(Tool(
82
+ name="tweek_vault",
83
+ description=(
84
+ "Retrieve a credential from Tweek's secure vault. "
85
+ "Credentials are stored in the system keychain, not in .env files. "
86
+ "Use this instead of reading .env files or hardcoding secrets."
87
+ ),
88
+ inputSchema={
89
+ "type": "object",
90
+ "properties": {
91
+ "skill": {
92
+ "type": "string",
93
+ "description": "Skill namespace for the credential",
94
+ },
95
+ "key": {
96
+ "type": "string",
97
+ "description": "Credential key name",
98
+ },
99
+ },
100
+ "required": ["skill", "key"],
101
+ },
102
+ ))
103
+
104
+ if tool_configs.get("status", True):
105
+ tools.append(Tool(
106
+ name="tweek_status",
107
+ description=(
108
+ "Show Tweek security status including active plugins, "
109
+ "recent activity, threat summary, and proxy statistics."
110
+ ),
111
+ inputSchema={
112
+ "type": "object",
113
+ "properties": {
114
+ "detail": {
115
+ "type": "string",
116
+ "enum": ["summary", "plugins", "activity", "threats"],
117
+ "description": "Level of detail (default: summary)",
118
+ },
119
+ },
120
+ },
121
+ ))
122
+
123
+ return tools
124
+
125
+ @self.server.call_tool()
126
+ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
127
+ """Handle tool calls."""
128
+ self._request_count += 1
129
+
130
+ handler_map = {
131
+ "tweek_vault": self._handle_vault,
132
+ "tweek_status": self._handle_status,
133
+ }
134
+
135
+ handler = handler_map.get(name)
136
+ if handler is None:
137
+ return [TextContent(
138
+ type="text",
139
+ text=json.dumps({
140
+ "error": f"Unknown tool: {name}",
141
+ "available_tools": list(handler_map.keys()),
142
+ }),
143
+ )]
144
+
145
+ try:
146
+ result = await handler(arguments)
147
+ return [TextContent(type="text", text=result)]
148
+ except Exception as e:
149
+ logger.error(f"Tool {name} failed: {e}")
150
+ return [TextContent(
151
+ type="text",
152
+ text=json.dumps({"error": str(e), "tool": name}),
153
+ )]
154
+
155
+ def _build_context(
156
+ self,
157
+ tool_name: str,
158
+ content: str,
159
+ tool_input: Optional[Dict[str, Any]] = None,
160
+ ) -> ScreeningContext:
161
+ """Build a ScreeningContext for MCP tool calls."""
162
+ return ScreeningContext(
163
+ tool_name=tool_name,
164
+ content=content,
165
+ tier="default",
166
+ working_dir=os.getcwd(),
167
+ source="mcp",
168
+ client_name=self.config.get("client_name"),
169
+ tool_input=tool_input,
170
+ )
171
+
172
+ def _run_screening(self, context: ScreeningContext) -> Dict[str, Any]:
173
+ """
174
+ Run the shared screening pipeline.
175
+
176
+ In gateway mode, should_prompt is converted to blocked
177
+ since there is no interactive user to confirm.
178
+ """
179
+ from tweek.mcp.screening import run_mcp_screening
180
+
181
+ result = run_mcp_screening(context)
182
+
183
+ if result.get("should_prompt"):
184
+ self._blocked_count += 1
185
+ return {
186
+ "allowed": False,
187
+ "blocked": True,
188
+ "reason": f"Requires user confirmation: {result.get('reason', '')}",
189
+ "findings": result.get("findings", []),
190
+ }
191
+
192
+ if result.get("blocked"):
193
+ self._blocked_count += 1
194
+
195
+ return {
196
+ "allowed": result.get("allowed", False),
197
+ "blocked": result.get("blocked", False),
198
+ "reason": result.get("reason"),
199
+ "findings": result.get("findings", []),
200
+ }
201
+
202
+ async def _handle_vault(self, arguments: Dict[str, Any]) -> str:
203
+ """Handle tweek_vault tool call."""
204
+ skill = arguments.get("skill", "")
205
+ key = arguments.get("key", "")
206
+
207
+ # Screen vault access
208
+ context = self._build_context("Vault", f"vault:{skill}/{key}", arguments)
209
+ screening = self._run_screening(context)
210
+
211
+ if screening["blocked"]:
212
+ return json.dumps({
213
+ "blocked": True,
214
+ "reason": screening["reason"],
215
+ })
216
+
217
+ try:
218
+ from tweek.vault.cross_platform import CrossPlatformVault
219
+
220
+ vault = CrossPlatformVault()
221
+ value = vault.get(skill, key)
222
+
223
+ if value is None:
224
+ return json.dumps({
225
+ "error": f"Credential not found: {skill}/{key}",
226
+ "available": False,
227
+ })
228
+
229
+ return json.dumps({
230
+ "value": value,
231
+ "skill": skill,
232
+ "key": key,
233
+ })
234
+
235
+ except Exception as e:
236
+ return json.dumps({"error": str(e)})
237
+
238
+ async def _handle_status(self, arguments: Dict[str, Any]) -> str:
239
+ """Handle tweek_status tool call."""
240
+ detail = arguments.get("detail", "summary")
241
+
242
+ try:
243
+ status = {
244
+ "version": MCP_SERVER_VERSION,
245
+ "source": "mcp",
246
+ "mode": "gateway",
247
+ "gateway_requests": self._request_count,
248
+ "gateway_blocked": self._blocked_count,
249
+ }
250
+
251
+ if detail in ("summary", "plugins"):
252
+ try:
253
+ from tweek.plugins import get_registry
254
+ registry = get_registry()
255
+ stats = registry.get_stats()
256
+ status["plugins"] = stats
257
+ except ImportError:
258
+ status["plugins"] = {"error": "Plugin system not available"}
259
+
260
+ if detail in ("summary", "activity"):
261
+ try:
262
+ from tweek.logging.security_log import get_logger as get_sec_logger
263
+ sec_logger = get_sec_logger()
264
+ recent = sec_logger.get_recent(limit=10)
265
+ status["recent_activity"] = [
266
+ {
267
+ "timestamp": str(e.timestamp),
268
+ "event_type": e.event_type.value,
269
+ "tool": e.tool_name,
270
+ "decision": e.decision,
271
+ }
272
+ for e in recent
273
+ ] if recent else []
274
+ except (ImportError, Exception):
275
+ status["recent_activity"] = []
276
+
277
+ # Include approval queue stats if available
278
+ try:
279
+ from tweek.mcp.approval import ApprovalQueue
280
+ queue = ApprovalQueue()
281
+ status["approval_queue"] = queue.get_stats()
282
+ except Exception:
283
+ pass
284
+
285
+ return json.dumps(status, indent=2)
286
+
287
+ except Exception as e:
288
+ return json.dumps({"error": str(e)})
289
+
290
+
291
+ async def run_server(config: Optional[Dict[str, Any]] = None):
292
+ """
293
+ Run the Tweek MCP gateway server on stdio transport.
294
+
295
+ Exposes tweek_vault and tweek_status tools. For upstream MCP
296
+ server interception, use run_proxy() instead.
297
+ """
298
+ _check_mcp_available()
299
+
300
+ server = TweekMCPServer(config=config)
301
+
302
+ logger.info("Starting Tweek MCP Gateway...")
303
+ logger.info(f"Version: {MCP_SERVER_VERSION}")
304
+ logger.info("Tools: tweek_vault, tweek_status")
305
+ logger.info("For upstream MCP interception, use: tweek mcp proxy")
306
+
307
+ async with stdio_server() as (read_stream, write_stream):
308
+ await server.server.run(
309
+ read_stream,
310
+ write_stream,
311
+ server.server.create_initialization_options(),
312
+ )
313
+
314
+
315
+ def create_server(config: Optional[Dict[str, Any]] = None) -> "TweekMCPServer":
316
+ """Create a TweekMCPServer instance for programmatic use."""
317
+ return TweekMCPServer(config=config)
@@ -0,0 +1,131 @@
1
+ """
2
+ Platform detection and cross-platform support for Tweek.
3
+
4
+ Tweek supports:
5
+ - macOS: Full support (Keychain via keyring, sandbox-exec)
6
+ - Linux: Full support (Secret Service via keyring, firejail optional)
7
+ - Windows: Partial support (Credential Locker via keyring, no sandbox)
8
+ """
9
+
10
+ import platform
11
+ import shutil
12
+ from dataclasses import dataclass
13
+ from enum import Enum
14
+ from typing import Optional
15
+
16
+
17
+ class Platform(Enum):
18
+ """Supported platforms."""
19
+ MACOS = "Darwin"
20
+ LINUX = "Linux"
21
+ WINDOWS = "Windows"
22
+ UNKNOWN = "Unknown"
23
+
24
+
25
+ @dataclass
26
+ class PlatformCapabilities:
27
+ """Capabilities available on the current platform."""
28
+ platform: Platform
29
+ vault_available: bool
30
+ vault_backend: str
31
+ sandbox_available: bool
32
+ sandbox_tool: Optional[str]
33
+ sandbox_install_hint: Optional[str]
34
+
35
+
36
+ # Detect current platform
37
+ PLATFORM_NAME = platform.system()
38
+
39
+ if PLATFORM_NAME == "Darwin":
40
+ PLATFORM = Platform.MACOS
41
+ elif PLATFORM_NAME == "Linux":
42
+ PLATFORM = Platform.LINUX
43
+ elif PLATFORM_NAME == "Windows":
44
+ PLATFORM = Platform.WINDOWS
45
+ else:
46
+ PLATFORM = Platform.UNKNOWN
47
+
48
+ IS_MACOS = PLATFORM == Platform.MACOS
49
+ IS_LINUX = PLATFORM == Platform.LINUX
50
+ IS_WINDOWS = PLATFORM == Platform.WINDOWS
51
+
52
+
53
+ def get_sandbox_tool() -> Optional[str]:
54
+ """Detect available sandbox tool."""
55
+ if IS_MACOS:
56
+ if shutil.which("sandbox-exec"):
57
+ return "sandbox-exec"
58
+ elif IS_LINUX:
59
+ if shutil.which("firejail"):
60
+ return "firejail"
61
+ elif shutil.which("bwrap"):
62
+ return "bubblewrap"
63
+ return None
64
+
65
+
66
+ def get_sandbox_install_hint() -> Optional[str]:
67
+ """Get installation hint for sandbox tool."""
68
+ if IS_MACOS:
69
+ return None # sandbox-exec is always available
70
+ elif IS_LINUX:
71
+ # Detect package manager
72
+ if shutil.which("apt"):
73
+ return "sudo apt install firejail"
74
+ elif shutil.which("dnf"):
75
+ return "sudo dnf install firejail"
76
+ elif shutil.which("pacman"):
77
+ return "sudo pacman -S firejail"
78
+ elif shutil.which("zypper"):
79
+ return "sudo zypper install firejail"
80
+ elif shutil.which("apk"):
81
+ return "sudo apk add firejail"
82
+ else:
83
+ return "Install firejail from https://firejail.wordpress.com/download-2/"
84
+ elif IS_WINDOWS:
85
+ return "Sandbox not available on Windows"
86
+ return None
87
+
88
+
89
+ def get_vault_backend() -> str:
90
+ """Get the vault backend name for current platform."""
91
+ if IS_MACOS:
92
+ return "macOS Keychain"
93
+ elif IS_LINUX:
94
+ return "Secret Service (GNOME Keyring/KWallet)"
95
+ elif IS_WINDOWS:
96
+ return "Windows Credential Locker"
97
+ return "Unknown"
98
+
99
+
100
+ def get_capabilities() -> PlatformCapabilities:
101
+ """Get capabilities for the current platform."""
102
+ sandbox_tool = get_sandbox_tool()
103
+
104
+ return PlatformCapabilities(
105
+ platform=PLATFORM,
106
+ vault_available=True, # keyring works everywhere
107
+ vault_backend=get_vault_backend(),
108
+ sandbox_available=sandbox_tool is not None,
109
+ sandbox_tool=sandbox_tool,
110
+ sandbox_install_hint=get_sandbox_install_hint() if not sandbox_tool else None,
111
+ )
112
+
113
+
114
+ def get_linux_package_manager() -> Optional[tuple[str, list[str]]]:
115
+ """Detect Linux package manager and return firejail install command."""
116
+ if not IS_LINUX:
117
+ return None
118
+
119
+ managers = {
120
+ "apt": ["sudo", "apt", "install", "-y", "firejail"],
121
+ "dnf": ["sudo", "dnf", "install", "-y", "firejail"],
122
+ "pacman": ["sudo", "pacman", "-S", "--noconfirm", "firejail"],
123
+ "zypper": ["sudo", "zypper", "install", "-y", "firejail"],
124
+ "apk": ["sudo", "apk", "add", "firejail"],
125
+ }
126
+
127
+ for manager, command in managers.items():
128
+ if shutil.which(manager):
129
+ return manager, command
130
+
131
+ return None