skilllite 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.
- skilllite/__init__.py +159 -0
- skilllite/analyzer.py +391 -0
- skilllite/builtin_tools.py +240 -0
- skilllite/cli.py +217 -0
- skilllite/core/__init__.py +65 -0
- skilllite/core/executor.py +182 -0
- skilllite/core/handler.py +332 -0
- skilllite/core/loops.py +770 -0
- skilllite/core/manager.py +507 -0
- skilllite/core/metadata.py +338 -0
- skilllite/core/prompt_builder.py +321 -0
- skilllite/core/registry.py +185 -0
- skilllite/core/skill_info.py +181 -0
- skilllite/core/tool_builder.py +338 -0
- skilllite/core/tools.py +253 -0
- skilllite/mcp/__init__.py +45 -0
- skilllite/mcp/server.py +734 -0
- skilllite/quick.py +420 -0
- skilllite/sandbox/__init__.py +36 -0
- skilllite/sandbox/base.py +93 -0
- skilllite/sandbox/config.py +229 -0
- skilllite/sandbox/skillbox/__init__.py +44 -0
- skilllite/sandbox/skillbox/binary.py +421 -0
- skilllite/sandbox/skillbox/executor.py +608 -0
- skilllite/sandbox/utils.py +77 -0
- skilllite/validation.py +137 -0
- skilllite-0.1.0.dist-info/METADATA +293 -0
- skilllite-0.1.0.dist-info/RECORD +32 -0
- skilllite-0.1.0.dist-info/WHEEL +5 -0
- skilllite-0.1.0.dist-info/entry_points.txt +3 -0
- skilllite-0.1.0.dist-info/licenses/LICENSE +21 -0
- skilllite-0.1.0.dist-info/top_level.txt +1 -0
skilllite/mcp/server.py
ADDED
|
@@ -0,0 +1,734 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MCP Server implementation for SkillLite.
|
|
3
|
+
|
|
4
|
+
This module provides a secure code execution sandbox server implementing
|
|
5
|
+
the Model Context Protocol (MCP).
|
|
6
|
+
|
|
7
|
+
Security Model:
|
|
8
|
+
The MCP server implements a two-phase execution model for security:
|
|
9
|
+
|
|
10
|
+
1. **scan_code**: First, scan the code for security issues. This returns
|
|
11
|
+
a detailed report of any potential risks found in the code.
|
|
12
|
+
|
|
13
|
+
2. **execute_code**: Then, execute the code. If security issues were found,
|
|
14
|
+
the caller must explicitly set `confirmed=true` to proceed.
|
|
15
|
+
|
|
16
|
+
This allows LLM clients to present security warnings to users and get
|
|
17
|
+
explicit confirmation before executing potentially dangerous code.
|
|
18
|
+
|
|
19
|
+
Environment Variables:
|
|
20
|
+
SKILLBOX_SANDBOX_LEVEL: Default sandbox level (1/2/3, default: 3)
|
|
21
|
+
SKILLBOX_PATH: Path to skillbox binary
|
|
22
|
+
MCP_SANDBOX_TIMEOUT: Execution timeout in seconds (default: 30)
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import asyncio
|
|
26
|
+
import hashlib
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import shutil
|
|
30
|
+
import subprocess
|
|
31
|
+
import tempfile
|
|
32
|
+
import time
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _load_dotenv():
|
|
38
|
+
"""Load environment variables from .env file if it exists."""
|
|
39
|
+
# Try to find .env file in current directory or parent directories
|
|
40
|
+
current_dir = Path.cwd()
|
|
41
|
+
|
|
42
|
+
for search_dir in [current_dir] + list(current_dir.parents)[:3]:
|
|
43
|
+
env_file = search_dir / ".env"
|
|
44
|
+
if env_file.exists():
|
|
45
|
+
try:
|
|
46
|
+
with open(env_file, "r") as f:
|
|
47
|
+
for line in f:
|
|
48
|
+
line = line.strip()
|
|
49
|
+
# Skip comments and empty lines
|
|
50
|
+
if not line or line.startswith("#"):
|
|
51
|
+
continue
|
|
52
|
+
# Parse KEY=VALUE
|
|
53
|
+
if "=" in line:
|
|
54
|
+
key, _, value = line.partition("=")
|
|
55
|
+
key = key.strip()
|
|
56
|
+
value = value.strip()
|
|
57
|
+
# Remove quotes if present
|
|
58
|
+
if value and value[0] in ('"', "'") and value[-1] == value[0]:
|
|
59
|
+
value = value[1:-1]
|
|
60
|
+
# Only set if not already in environment
|
|
61
|
+
if key and key not in os.environ:
|
|
62
|
+
os.environ[key] = value
|
|
63
|
+
return True
|
|
64
|
+
except Exception:
|
|
65
|
+
pass
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# Load .env file on module import
|
|
70
|
+
_load_dotenv()
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
from mcp.server import Server
|
|
74
|
+
from mcp.server.stdio import stdio_server
|
|
75
|
+
from mcp.types import (
|
|
76
|
+
TextContent,
|
|
77
|
+
Tool,
|
|
78
|
+
CallToolResult,
|
|
79
|
+
)
|
|
80
|
+
MCP_AVAILABLE = True
|
|
81
|
+
except ImportError:
|
|
82
|
+
MCP_AVAILABLE = False
|
|
83
|
+
# Define stub types for when MCP is not available
|
|
84
|
+
Server = None # type: ignore
|
|
85
|
+
stdio_server = None # type: ignore
|
|
86
|
+
TextContent = None # type: ignore
|
|
87
|
+
Tool = None # type: ignore
|
|
88
|
+
CallToolResult = None # type: ignore
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class SecurityScanResult:
|
|
92
|
+
"""Result of a security scan."""
|
|
93
|
+
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
is_safe: bool,
|
|
97
|
+
issues: List[Dict[str, Any]],
|
|
98
|
+
scan_id: str,
|
|
99
|
+
code_hash: str,
|
|
100
|
+
high_severity_count: int = 0,
|
|
101
|
+
medium_severity_count: int = 0,
|
|
102
|
+
low_severity_count: int = 0,
|
|
103
|
+
):
|
|
104
|
+
self.is_safe = is_safe
|
|
105
|
+
self.issues = issues
|
|
106
|
+
self.scan_id = scan_id
|
|
107
|
+
self.code_hash = code_hash
|
|
108
|
+
self.high_severity_count = high_severity_count
|
|
109
|
+
self.medium_severity_count = medium_severity_count
|
|
110
|
+
self.low_severity_count = low_severity_count
|
|
111
|
+
self.timestamp = time.time()
|
|
112
|
+
|
|
113
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
114
|
+
return {
|
|
115
|
+
"is_safe": self.is_safe,
|
|
116
|
+
"issues": self.issues,
|
|
117
|
+
"scan_id": self.scan_id,
|
|
118
|
+
"code_hash": self.code_hash,
|
|
119
|
+
"high_severity_count": self.high_severity_count,
|
|
120
|
+
"medium_severity_count": self.medium_severity_count,
|
|
121
|
+
"low_severity_count": self.low_severity_count,
|
|
122
|
+
"requires_confirmation": self.high_severity_count > 0,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
def format_report(self) -> str:
|
|
126
|
+
"""Format a human-readable security report."""
|
|
127
|
+
if not self.issues:
|
|
128
|
+
return "✅ Security scan passed. No issues found."
|
|
129
|
+
|
|
130
|
+
lines = [
|
|
131
|
+
f"📋 Security Scan Report (ID: {self.scan_id[:8]})",
|
|
132
|
+
f" Found {len(self.issues)} item(s) for review:",
|
|
133
|
+
"",
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
severity_icons = {
|
|
137
|
+
"Critical": "🔴",
|
|
138
|
+
"High": "🟠",
|
|
139
|
+
"Medium": "🟡",
|
|
140
|
+
"Low": "🟢",
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
for idx, issue in enumerate(self.issues, 1):
|
|
144
|
+
severity = issue.get("severity", "Medium")
|
|
145
|
+
icon = severity_icons.get(severity, "⚪")
|
|
146
|
+
lines.append(f" {icon} #{idx} [{severity}] {issue.get('issue_type', 'Unknown')}")
|
|
147
|
+
lines.append(f" ├─ Rule: {issue.get('rule_id', 'N/A')}")
|
|
148
|
+
lines.append(f" ├─ Line {issue.get('line_number', '?')}: {issue.get('description', '')}")
|
|
149
|
+
lines.append(f" └─ Code: {issue.get('code_snippet', '')[:60]}...")
|
|
150
|
+
lines.append("")
|
|
151
|
+
|
|
152
|
+
if self.high_severity_count > 0:
|
|
153
|
+
lines.append("⚠️ High severity issues found. Confirmation required to execute.")
|
|
154
|
+
lines.append(f" To proceed, call execute_code with confirmed=true and scan_id=\"{self.scan_id}\"")
|
|
155
|
+
else:
|
|
156
|
+
lines.append("ℹ️ Only low/medium severity issues found. Safe to execute.")
|
|
157
|
+
|
|
158
|
+
return "\n".join(lines)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class SandboxExecutor:
|
|
162
|
+
"""Secure code execution sandbox using Rust skillbox."""
|
|
163
|
+
|
|
164
|
+
# Cache scan results for confirmation flow (scan_id -> result)
|
|
165
|
+
_scan_cache: Dict[str, SecurityScanResult] = {}
|
|
166
|
+
# Cache expiry time in seconds
|
|
167
|
+
SCAN_CACHE_TTL = 300 # 5 minutes
|
|
168
|
+
|
|
169
|
+
def __init__(self):
|
|
170
|
+
from ..sandbox.skillbox import find_binary
|
|
171
|
+
|
|
172
|
+
self.skillbox_path = os.getenv("SKILLBOX_PATH") or find_binary() or "./skillbox/target/release/skillbox"
|
|
173
|
+
self.timeout = int(os.getenv("MCP_SANDBOX_TIMEOUT", "30"))
|
|
174
|
+
self.runtime_available = os.path.exists(self.skillbox_path) and os.access(self.skillbox_path, os.X_OK)
|
|
175
|
+
|
|
176
|
+
# Read default sandbox level from environment variable
|
|
177
|
+
# SKILLBOX_SANDBOX_LEVEL: 1=no sandbox, 2=sandbox only, 3=sandbox+scan (default)
|
|
178
|
+
default_level = os.getenv("SKILLBOX_SANDBOX_LEVEL", "3")
|
|
179
|
+
try:
|
|
180
|
+
self.default_sandbox_level = int(default_level)
|
|
181
|
+
if self.default_sandbox_level not in [1, 2, 3]:
|
|
182
|
+
self.default_sandbox_level = 3
|
|
183
|
+
except ValueError:
|
|
184
|
+
self.default_sandbox_level = 3
|
|
185
|
+
|
|
186
|
+
def _generate_code_hash(self, language: str, code: str) -> str:
|
|
187
|
+
"""Generate a hash of the code for verification."""
|
|
188
|
+
content = f"{language}:{code}"
|
|
189
|
+
return hashlib.sha256(content.encode()).hexdigest()
|
|
190
|
+
|
|
191
|
+
def _generate_scan_id(self, code_hash: str) -> str:
|
|
192
|
+
"""Generate a unique scan ID."""
|
|
193
|
+
timestamp = str(time.time())
|
|
194
|
+
return hashlib.sha256(f"{code_hash}:{timestamp}".encode()).hexdigest()[:16]
|
|
195
|
+
|
|
196
|
+
def _cleanup_expired_scans(self):
|
|
197
|
+
"""Remove expired scan results from cache."""
|
|
198
|
+
current_time = time.time()
|
|
199
|
+
expired_ids = [
|
|
200
|
+
scan_id for scan_id, result in self._scan_cache.items()
|
|
201
|
+
if current_time - result.timestamp > self.SCAN_CACHE_TTL
|
|
202
|
+
]
|
|
203
|
+
for scan_id in expired_ids:
|
|
204
|
+
del self._scan_cache[scan_id]
|
|
205
|
+
|
|
206
|
+
def _create_temp_skill(self, language: str, code: str) -> Tuple[str, str]:
|
|
207
|
+
"""Create a temporary skill directory with the code file."""
|
|
208
|
+
skill_dir = tempfile.mkdtemp(prefix="mcp_skill_")
|
|
209
|
+
|
|
210
|
+
# Create scripts subdirectory (required by skillbox)
|
|
211
|
+
scripts_dir = os.path.join(skill_dir, "scripts")
|
|
212
|
+
os.makedirs(scripts_dir, exist_ok=True)
|
|
213
|
+
|
|
214
|
+
ext = self.get_file_extension(language)
|
|
215
|
+
entry_point = f"scripts/main.{ext}"
|
|
216
|
+
|
|
217
|
+
skill_md_content = f"""---
|
|
218
|
+
name: mcp-execution
|
|
219
|
+
entry_point: {entry_point}
|
|
220
|
+
language: {language}
|
|
221
|
+
description: MCP code execution skill
|
|
222
|
+
network:
|
|
223
|
+
enabled: true
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
This skill executes code from MCP.
|
|
227
|
+
"""
|
|
228
|
+
with open(os.path.join(skill_dir, "SKILL.md"), "w") as f:
|
|
229
|
+
f.write(skill_md_content)
|
|
230
|
+
|
|
231
|
+
code_file = os.path.join(scripts_dir, f"main.{ext}")
|
|
232
|
+
with open(code_file, "w") as f:
|
|
233
|
+
f.write(code)
|
|
234
|
+
os.chmod(code_file, 0o755)
|
|
235
|
+
|
|
236
|
+
return skill_dir, code_file
|
|
237
|
+
|
|
238
|
+
def scan_code(self, language: str, code: str) -> SecurityScanResult:
|
|
239
|
+
"""Scan code for security issues without executing it."""
|
|
240
|
+
if not self.runtime_available:
|
|
241
|
+
return SecurityScanResult(
|
|
242
|
+
is_safe=False,
|
|
243
|
+
issues=[{"severity": "Critical", "issue_type": "RuntimeError",
|
|
244
|
+
"description": f"skillbox not found at {self.skillbox_path}",
|
|
245
|
+
"rule_id": "system", "line_number": 0, "code_snippet": ""}],
|
|
246
|
+
scan_id="error",
|
|
247
|
+
code_hash="",
|
|
248
|
+
high_severity_count=1,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
self._cleanup_expired_scans()
|
|
252
|
+
code_hash = self._generate_code_hash(language, code)
|
|
253
|
+
scan_id = self._generate_scan_id(code_hash)
|
|
254
|
+
|
|
255
|
+
try:
|
|
256
|
+
skill_dir, code_file = self._create_temp_skill(language, code)
|
|
257
|
+
|
|
258
|
+
try:
|
|
259
|
+
result = subprocess.run(
|
|
260
|
+
[self.skillbox_path, "security-scan", code_file],
|
|
261
|
+
capture_output=True,
|
|
262
|
+
text=True,
|
|
263
|
+
timeout=30
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
issues = self._parse_scan_output(result.stdout + result.stderr)
|
|
267
|
+
high_count = sum(1 for i in issues if i.get("severity") in ["Critical", "High"])
|
|
268
|
+
medium_count = sum(1 for i in issues if i.get("severity") == "Medium")
|
|
269
|
+
low_count = sum(1 for i in issues if i.get("severity") == "Low")
|
|
270
|
+
|
|
271
|
+
scan_result = SecurityScanResult(
|
|
272
|
+
is_safe=high_count == 0,
|
|
273
|
+
issues=issues,
|
|
274
|
+
scan_id=scan_id,
|
|
275
|
+
code_hash=code_hash,
|
|
276
|
+
high_severity_count=high_count,
|
|
277
|
+
medium_severity_count=medium_count,
|
|
278
|
+
low_severity_count=low_count,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
self._scan_cache[scan_id] = scan_result
|
|
282
|
+
return scan_result
|
|
283
|
+
|
|
284
|
+
finally:
|
|
285
|
+
shutil.rmtree(skill_dir, ignore_errors=True)
|
|
286
|
+
|
|
287
|
+
except subprocess.TimeoutExpired:
|
|
288
|
+
return SecurityScanResult(
|
|
289
|
+
is_safe=False,
|
|
290
|
+
issues=[{"severity": "Critical", "issue_type": "Timeout",
|
|
291
|
+
"description": "Security scan timed out",
|
|
292
|
+
"rule_id": "system", "line_number": 0, "code_snippet": ""}],
|
|
293
|
+
scan_id=scan_id,
|
|
294
|
+
code_hash=code_hash,
|
|
295
|
+
high_severity_count=1,
|
|
296
|
+
)
|
|
297
|
+
except Exception as e:
|
|
298
|
+
return SecurityScanResult(
|
|
299
|
+
is_safe=False,
|
|
300
|
+
issues=[{"severity": "Critical", "issue_type": "ScanError",
|
|
301
|
+
"description": str(e),
|
|
302
|
+
"rule_id": "system", "line_number": 0, "code_snippet": ""}],
|
|
303
|
+
scan_id=scan_id,
|
|
304
|
+
code_hash=code_hash,
|
|
305
|
+
high_severity_count=1,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
def _parse_scan_output(self, output: str) -> List[Dict[str, Any]]:
|
|
309
|
+
"""Parse security scan output into structured issues."""
|
|
310
|
+
issues = []
|
|
311
|
+
lines = output.split("\n")
|
|
312
|
+
|
|
313
|
+
current_issue = None
|
|
314
|
+
for line in lines:
|
|
315
|
+
line = line.strip()
|
|
316
|
+
|
|
317
|
+
if line.startswith(("🔴", "🟠", "🟡", "🟢", "🚨")):
|
|
318
|
+
if current_issue:
|
|
319
|
+
issues.append(current_issue)
|
|
320
|
+
|
|
321
|
+
severity = "Medium"
|
|
322
|
+
if "🔴" in line or "🚨" in line:
|
|
323
|
+
severity = "Critical" if "🚨" in line else "High"
|
|
324
|
+
elif "🟠" in line:
|
|
325
|
+
severity = "High"
|
|
326
|
+
elif "🟡" in line:
|
|
327
|
+
severity = "Medium"
|
|
328
|
+
elif "🟢" in line:
|
|
329
|
+
severity = "Low"
|
|
330
|
+
|
|
331
|
+
issue_type = "Unknown"
|
|
332
|
+
if "[" in line and "]" in line:
|
|
333
|
+
start = line.index("[") + 1
|
|
334
|
+
end = line.index("]")
|
|
335
|
+
issue_type = line[end+1:].strip() if end + 1 < len(line) else "Unknown"
|
|
336
|
+
|
|
337
|
+
current_issue = {
|
|
338
|
+
"severity": severity,
|
|
339
|
+
"issue_type": issue_type,
|
|
340
|
+
"rule_id": "",
|
|
341
|
+
"line_number": 0,
|
|
342
|
+
"description": "",
|
|
343
|
+
"code_snippet": "",
|
|
344
|
+
}
|
|
345
|
+
elif current_issue:
|
|
346
|
+
if "Rule:" in line:
|
|
347
|
+
current_issue["rule_id"] = line.split("Rule:")[-1].strip()
|
|
348
|
+
elif "Line" in line and ":" in line:
|
|
349
|
+
parts = line.split(":", 1)
|
|
350
|
+
if len(parts) > 1:
|
|
351
|
+
try:
|
|
352
|
+
line_part = parts[0].replace("├─", "").replace("Line", "").strip()
|
|
353
|
+
current_issue["line_number"] = int(line_part)
|
|
354
|
+
current_issue["description"] = parts[1].strip()
|
|
355
|
+
except ValueError:
|
|
356
|
+
current_issue["description"] = line
|
|
357
|
+
elif "Code:" in line:
|
|
358
|
+
current_issue["code_snippet"] = line.split("Code:")[-1].strip()
|
|
359
|
+
|
|
360
|
+
if current_issue:
|
|
361
|
+
issues.append(current_issue)
|
|
362
|
+
|
|
363
|
+
return issues
|
|
364
|
+
|
|
365
|
+
def verify_scan(self, scan_id: str, code_hash: str) -> Optional[SecurityScanResult]:
|
|
366
|
+
"""Verify a scan result exists and matches the code hash."""
|
|
367
|
+
self._cleanup_expired_scans()
|
|
368
|
+
|
|
369
|
+
if scan_id not in self._scan_cache:
|
|
370
|
+
return None
|
|
371
|
+
|
|
372
|
+
result = self._scan_cache[scan_id]
|
|
373
|
+
if result.code_hash != code_hash:
|
|
374
|
+
return None
|
|
375
|
+
|
|
376
|
+
return result
|
|
377
|
+
|
|
378
|
+
def execute(
|
|
379
|
+
self,
|
|
380
|
+
language: str,
|
|
381
|
+
code: str,
|
|
382
|
+
confirmed: bool = False,
|
|
383
|
+
scan_id: Optional[str] = None,
|
|
384
|
+
sandbox_level: Optional[int] = None,
|
|
385
|
+
) -> Dict[str, Any]:
|
|
386
|
+
"""Execute code in a secure sandbox using Rust skillbox.
|
|
387
|
+
|
|
388
|
+
Args:
|
|
389
|
+
language: Programming language (python, javascript, bash)
|
|
390
|
+
code: Code to execute
|
|
391
|
+
confirmed: Whether user has confirmed execution despite security warnings
|
|
392
|
+
scan_id: Scan ID from previous scan_code call (required when confirmed=True)
|
|
393
|
+
sandbox_level: Override sandbox level (default: from SKILLBOX_SANDBOX_LEVEL env or 3)
|
|
394
|
+
"""
|
|
395
|
+
# Use default sandbox level from environment if not specified
|
|
396
|
+
if sandbox_level is None:
|
|
397
|
+
sandbox_level = self.default_sandbox_level
|
|
398
|
+
if not self.runtime_available:
|
|
399
|
+
return {
|
|
400
|
+
"success": False,
|
|
401
|
+
"stdout": "",
|
|
402
|
+
"stderr": f"skillbox not found at {self.skillbox_path}. Please build it with: cd skillbox && cargo build --release",
|
|
403
|
+
"exit_code": 1
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
code_hash = self._generate_code_hash(language, code)
|
|
407
|
+
|
|
408
|
+
if sandbox_level >= 3 and not confirmed:
|
|
409
|
+
scan_result = self.scan_code(language, code)
|
|
410
|
+
if scan_result.high_severity_count > 0:
|
|
411
|
+
return {
|
|
412
|
+
"success": False,
|
|
413
|
+
"stdout": "",
|
|
414
|
+
"stderr": (
|
|
415
|
+
f"🔐 Security Review Required\n\n"
|
|
416
|
+
f"{scan_result.format_report()}\n\n"
|
|
417
|
+
f"To execute this code, call execute_code again with:\n"
|
|
418
|
+
f" - confirmed: true\n"
|
|
419
|
+
f" - scan_id: \"{scan_result.scan_id}\"\n"
|
|
420
|
+
),
|
|
421
|
+
"exit_code": 2,
|
|
422
|
+
"requires_confirmation": True,
|
|
423
|
+
"scan_id": scan_result.scan_id,
|
|
424
|
+
"security_issues": scan_result.to_dict(),
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if confirmed and scan_id:
|
|
428
|
+
cached_result = self.verify_scan(scan_id, code_hash)
|
|
429
|
+
if not cached_result:
|
|
430
|
+
return {
|
|
431
|
+
"success": False,
|
|
432
|
+
"stdout": "",
|
|
433
|
+
"stderr": (
|
|
434
|
+
"❌ Invalid or expired scan_id. The code may have been modified.\n"
|
|
435
|
+
"Please run scan_code again to get a new scan_id."
|
|
436
|
+
),
|
|
437
|
+
"exit_code": 3,
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
try:
|
|
441
|
+
skill_dir, _ = self._create_temp_skill(language, code)
|
|
442
|
+
|
|
443
|
+
try:
|
|
444
|
+
env = os.environ.copy()
|
|
445
|
+
env["SKILLBOX_AUTO_APPROVE"] = "true"
|
|
446
|
+
|
|
447
|
+
cmd = [self.skillbox_path, "run"]
|
|
448
|
+
if sandbox_level in [1, 2, 3]:
|
|
449
|
+
cmd.extend(["--sandbox-level", str(sandbox_level)])
|
|
450
|
+
cmd.extend([skill_dir, "{}"])
|
|
451
|
+
|
|
452
|
+
result = subprocess.run(
|
|
453
|
+
cmd,
|
|
454
|
+
capture_output=True,
|
|
455
|
+
text=True,
|
|
456
|
+
timeout=self.timeout,
|
|
457
|
+
env=env,
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
return {
|
|
461
|
+
"success": result.returncode == 0,
|
|
462
|
+
"stdout": result.stdout,
|
|
463
|
+
"stderr": result.stderr,
|
|
464
|
+
"exit_code": result.returncode
|
|
465
|
+
}
|
|
466
|
+
finally:
|
|
467
|
+
shutil.rmtree(skill_dir, ignore_errors=True)
|
|
468
|
+
|
|
469
|
+
except subprocess.TimeoutExpired:
|
|
470
|
+
return {
|
|
471
|
+
"success": False,
|
|
472
|
+
"stdout": "",
|
|
473
|
+
"stderr": f"Execution timed out after {self.timeout} seconds",
|
|
474
|
+
"exit_code": 124
|
|
475
|
+
}
|
|
476
|
+
except Exception as e:
|
|
477
|
+
return {
|
|
478
|
+
"success": False,
|
|
479
|
+
"stdout": "",
|
|
480
|
+
"stderr": str(e),
|
|
481
|
+
"exit_code": 1
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
def get_file_extension(self, language: str) -> str:
|
|
485
|
+
"""Get file extension for the given language."""
|
|
486
|
+
extensions = {
|
|
487
|
+
"python": "py",
|
|
488
|
+
"javascript": "js",
|
|
489
|
+
"bash": "sh"
|
|
490
|
+
}
|
|
491
|
+
return extensions.get(language, "py")
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
class MCPServer:
|
|
495
|
+
"""MCP server for SkillLite sandbox execution.
|
|
496
|
+
|
|
497
|
+
This server provides two tools for secure code execution:
|
|
498
|
+
|
|
499
|
+
1. **scan_code**: Scan code for security issues before execution.
|
|
500
|
+
Returns a detailed report and a scan_id for confirmation.
|
|
501
|
+
|
|
502
|
+
2. **execute_code**: Execute code in a sandbox. If high-severity
|
|
503
|
+
security issues are found, requires explicit confirmation.
|
|
504
|
+
|
|
505
|
+
Example workflow:
|
|
506
|
+
1. Call scan_code to check for security issues
|
|
507
|
+
2. Review the security report with the user
|
|
508
|
+
3. If user approves, call execute_code with confirmed=true and scan_id
|
|
509
|
+
"""
|
|
510
|
+
|
|
511
|
+
def __init__(self):
|
|
512
|
+
if not MCP_AVAILABLE:
|
|
513
|
+
raise ImportError(
|
|
514
|
+
"MCP library not available. Please install it with: "
|
|
515
|
+
"pip install skilllite[mcp]"
|
|
516
|
+
)
|
|
517
|
+
self.server = Server("skilllite-mcp-server")
|
|
518
|
+
self.executor = SandboxExecutor()
|
|
519
|
+
self._setup_handlers()
|
|
520
|
+
|
|
521
|
+
def _setup_handlers(self):
|
|
522
|
+
"""Setup MCP server handlers."""
|
|
523
|
+
|
|
524
|
+
@self.server.list_tools()
|
|
525
|
+
async def list_tools() -> List[Tool]:
|
|
526
|
+
return [
|
|
527
|
+
Tool(
|
|
528
|
+
name="scan_code",
|
|
529
|
+
description=(
|
|
530
|
+
"Scan code for security issues before execution. "
|
|
531
|
+
"Returns a security report with any potential risks found. "
|
|
532
|
+
"Use this before execute_code to review security implications."
|
|
533
|
+
),
|
|
534
|
+
inputSchema={
|
|
535
|
+
"type": "object",
|
|
536
|
+
"properties": {
|
|
537
|
+
"language": {
|
|
538
|
+
"type": "string",
|
|
539
|
+
"enum": ["python", "javascript", "bash"],
|
|
540
|
+
"description": "Programming language of the code"
|
|
541
|
+
},
|
|
542
|
+
"code": {
|
|
543
|
+
"type": "string",
|
|
544
|
+
"description": "Code to scan for security issues"
|
|
545
|
+
}
|
|
546
|
+
},
|
|
547
|
+
"required": ["language", "code"]
|
|
548
|
+
}
|
|
549
|
+
),
|
|
550
|
+
Tool(
|
|
551
|
+
name="execute_code",
|
|
552
|
+
description=(
|
|
553
|
+
"Execute code in a secure sandbox environment. "
|
|
554
|
+
"If security issues are found, you must set confirmed=true "
|
|
555
|
+
"and provide the scan_id from a previous scan_code call."
|
|
556
|
+
),
|
|
557
|
+
inputSchema={
|
|
558
|
+
"type": "object",
|
|
559
|
+
"properties": {
|
|
560
|
+
"language": {
|
|
561
|
+
"type": "string",
|
|
562
|
+
"enum": ["python", "javascript", "bash"],
|
|
563
|
+
"description": "Programming language to execute"
|
|
564
|
+
},
|
|
565
|
+
"code": {
|
|
566
|
+
"type": "string",
|
|
567
|
+
"description": "Code to execute"
|
|
568
|
+
},
|
|
569
|
+
"confirmed": {
|
|
570
|
+
"type": "boolean",
|
|
571
|
+
"description": (
|
|
572
|
+
"Set to true to confirm execution despite security warnings. "
|
|
573
|
+
"Required when high-severity issues are found."
|
|
574
|
+
),
|
|
575
|
+
"default": False
|
|
576
|
+
},
|
|
577
|
+
"scan_id": {
|
|
578
|
+
"type": "string",
|
|
579
|
+
"description": (
|
|
580
|
+
"The scan_id from a previous scan_code call. "
|
|
581
|
+
"Required when confirmed=true to verify the code hasn't changed."
|
|
582
|
+
)
|
|
583
|
+
},
|
|
584
|
+
"sandbox_level": {
|
|
585
|
+
"type": "integer",
|
|
586
|
+
"enum": [1, 2, 3],
|
|
587
|
+
"description": (
|
|
588
|
+
"Sandbox security level: "
|
|
589
|
+
"1=no sandbox, 2=sandbox only, 3=sandbox+security scan (default)"
|
|
590
|
+
),
|
|
591
|
+
"default": 3
|
|
592
|
+
}
|
|
593
|
+
},
|
|
594
|
+
"required": ["language", "code"]
|
|
595
|
+
}
|
|
596
|
+
)
|
|
597
|
+
]
|
|
598
|
+
|
|
599
|
+
@self.server.call_tool()
|
|
600
|
+
async def call_tool(
|
|
601
|
+
name: str,
|
|
602
|
+
arguments: Dict[str, Any]
|
|
603
|
+
) -> "CallToolResult":
|
|
604
|
+
if name == "scan_code":
|
|
605
|
+
return await self._handle_scan_code(arguments)
|
|
606
|
+
elif name == "execute_code":
|
|
607
|
+
return await self._handle_execute_code(arguments)
|
|
608
|
+
else:
|
|
609
|
+
return CallToolResult(
|
|
610
|
+
isError=True,
|
|
611
|
+
content=[
|
|
612
|
+
TextContent(
|
|
613
|
+
type="text",
|
|
614
|
+
text=f"Unknown tool: {name}"
|
|
615
|
+
)
|
|
616
|
+
]
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
async def _handle_scan_code(self, arguments: Dict[str, Any]) -> "CallToolResult":
|
|
620
|
+
"""Handle scan_code tool call."""
|
|
621
|
+
language = arguments.get("language")
|
|
622
|
+
code = arguments.get("code")
|
|
623
|
+
|
|
624
|
+
if not language or not code:
|
|
625
|
+
return CallToolResult(
|
|
626
|
+
isError=True,
|
|
627
|
+
content=[
|
|
628
|
+
TextContent(
|
|
629
|
+
type="text",
|
|
630
|
+
text="Missing required arguments: language and code"
|
|
631
|
+
)
|
|
632
|
+
]
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
scan_result = self.executor.scan_code(language, code)
|
|
636
|
+
|
|
637
|
+
report = scan_result.format_report()
|
|
638
|
+
|
|
639
|
+
result_json = json.dumps(scan_result.to_dict(), indent=2)
|
|
640
|
+
|
|
641
|
+
return CallToolResult(
|
|
642
|
+
content=[
|
|
643
|
+
TextContent(
|
|
644
|
+
type="text",
|
|
645
|
+
text=f"{report}\n\n---\nScan Details (JSON):\n{result_json}"
|
|
646
|
+
)
|
|
647
|
+
]
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
async def _handle_execute_code(self, arguments: Dict[str, Any]) -> "CallToolResult":
|
|
651
|
+
"""Handle execute_code tool call."""
|
|
652
|
+
language = arguments.get("language")
|
|
653
|
+
code = arguments.get("code")
|
|
654
|
+
confirmed = arguments.get("confirmed", False)
|
|
655
|
+
scan_id = arguments.get("scan_id")
|
|
656
|
+
# sandbox_level: None means use default from environment variable
|
|
657
|
+
sandbox_level = arguments.get("sandbox_level")
|
|
658
|
+
|
|
659
|
+
if not language or not code:
|
|
660
|
+
return CallToolResult(
|
|
661
|
+
isError=True,
|
|
662
|
+
content=[
|
|
663
|
+
TextContent(
|
|
664
|
+
type="text",
|
|
665
|
+
text="Missing required arguments: language and code"
|
|
666
|
+
)
|
|
667
|
+
]
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
result = self.executor.execute(
|
|
671
|
+
language=language,
|
|
672
|
+
code=code,
|
|
673
|
+
confirmed=confirmed,
|
|
674
|
+
scan_id=scan_id,
|
|
675
|
+
sandbox_level=sandbox_level,
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
if result.get("requires_confirmation"):
|
|
679
|
+
return CallToolResult(
|
|
680
|
+
content=[
|
|
681
|
+
TextContent(
|
|
682
|
+
type="text",
|
|
683
|
+
text=result["stderr"]
|
|
684
|
+
)
|
|
685
|
+
]
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
output_lines = []
|
|
689
|
+
if result["stdout"]:
|
|
690
|
+
output_lines.append(f"Output:\n{result['stdout']}")
|
|
691
|
+
if result["stderr"]:
|
|
692
|
+
output_lines.append(f"Errors:\n{result['stderr']}")
|
|
693
|
+
|
|
694
|
+
output_text = "\n".join(output_lines) if output_lines else "Execution completed successfully (no output)"
|
|
695
|
+
|
|
696
|
+
if result["success"]:
|
|
697
|
+
return CallToolResult(
|
|
698
|
+
content=[
|
|
699
|
+
TextContent(
|
|
700
|
+
type="text",
|
|
701
|
+
text=output_text
|
|
702
|
+
)
|
|
703
|
+
]
|
|
704
|
+
)
|
|
705
|
+
else:
|
|
706
|
+
return CallToolResult(
|
|
707
|
+
isError=True,
|
|
708
|
+
content=[
|
|
709
|
+
TextContent(
|
|
710
|
+
type="text",
|
|
711
|
+
text=f"Execution failed:\n{output_text}"
|
|
712
|
+
)
|
|
713
|
+
]
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
async def run(self):
|
|
717
|
+
"""Run the MCP server."""
|
|
718
|
+
async with stdio_server() as (read_stream, write_stream):
|
|
719
|
+
await self.server.run(read_stream, write_stream, self.server.create_initialization_options())
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
async def main():
|
|
723
|
+
"""Main entry point for the MCP sandbox server."""
|
|
724
|
+
if not MCP_AVAILABLE:
|
|
725
|
+
print("Error: MCP library not available")
|
|
726
|
+
print("Please install it with: pip install skilllite[mcp]")
|
|
727
|
+
return
|
|
728
|
+
|
|
729
|
+
server = MCPServer()
|
|
730
|
+
await server.run()
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
if __name__ == "__main__":
|
|
734
|
+
asyncio.run(main())
|