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