skilllite 0.1.0__py3-none-any.whl → 0.1.2__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/quick.py CHANGED
@@ -92,7 +92,8 @@ class SkillRunner:
92
92
  allow_network: Optional[bool] = None,
93
93
  enable_sandbox: Optional[bool] = None,
94
94
  execution_timeout: Optional[int] = None,
95
- max_memory_mb: Optional[int] = None
95
+ max_memory_mb: Optional[int] = None,
96
+ confirmation_callback: Optional[Callable[[str, str], bool]] = None
96
97
  ):
97
98
  """
98
99
  Initialize SkillRunner.
@@ -122,6 +123,9 @@ class SkillRunner:
122
123
  enable_sandbox: Whether to enable sandbox protection (defaults from .env or True)
123
124
  execution_timeout: Skill execution timeout in seconds (defaults from .env or 120)
124
125
  max_memory_mb: Maximum memory limit in MB (defaults from .env or 512)
126
+ confirmation_callback: Callback for security confirmation when sandbox_level=3.
127
+ Signature: (security_report: str, scan_id: str) -> bool
128
+ If None and sandbox_level=3, will use interactive terminal confirmation.
125
129
  """
126
130
  # Load .env
127
131
  load_env(env_file)
@@ -161,7 +165,11 @@ class SkillRunner:
161
165
 
162
166
  self.custom_tool_executor = custom_tool_executor
163
167
  self.use_enhanced_loop = use_enhanced_loop
164
-
168
+
169
+ # Security confirmation callback
170
+ # If None and sandbox_level=3, will use interactive terminal confirmation
171
+ self.confirmation_callback = confirmation_callback
172
+
165
173
  # Lazy initialization
166
174
  self._client = None
167
175
  self._manager = None
@@ -282,7 +290,8 @@ Example of CORRECT approach:
282
290
  model=self.model,
283
291
  max_iterations=self.max_iterations,
284
292
  custom_tools=self.custom_tools if self.custom_tools else None,
285
- custom_tool_executor=tool_executor
293
+ custom_tool_executor=tool_executor,
294
+ confirmation_callback=self.confirmation_callback
286
295
  )
287
296
  else:
288
297
  # Use basic AgenticLoop (backward compatible)
@@ -290,7 +299,8 @@ Example of CORRECT approach:
290
299
  client=self.client,
291
300
  model=self.model,
292
301
  system_prompt=self.system_context,
293
- max_iterations=self.max_iterations
302
+ max_iterations=self.max_iterations,
303
+ confirmation_callback=self.confirmation_callback
294
304
  )
295
305
 
296
306
  response = loop.run(user_message)
@@ -0,0 +1,155 @@
1
+ """
2
+ Execution Context - Encapsulates all configuration for a single execution.
3
+
4
+ This module provides the ExecutionContext class which is the single source of truth
5
+ for execution configuration. It reads from environment variables at runtime,
6
+ ensuring that any changes to environment variables are immediately reflected.
7
+
8
+ Design Principles:
9
+ 1. Read configuration at runtime, not at initialization
10
+ 2. Never cache configuration values
11
+ 3. Support temporary overrides via with_override()
12
+ 4. Immutable - create new instances for modifications
13
+ """
14
+
15
+ import os
16
+ from dataclasses import dataclass, field
17
+ from typing import Optional
18
+
19
+
20
+ # Default configuration values
21
+ DEFAULT_SANDBOX_LEVEL = "3"
22
+ DEFAULT_TIMEOUT = 120
23
+ DEFAULT_MAX_MEMORY_MB = 512
24
+ DEFAULT_ALLOW_NETWORK = False
25
+ DEFAULT_AUTO_APPROVE = False
26
+
27
+
28
+ def _parse_bool_env(key: str, default: bool) -> bool:
29
+ """Parse a boolean value from environment variable."""
30
+ value = os.environ.get(key)
31
+ if value is None:
32
+ return default
33
+ value_lower = value.lower().strip()
34
+ if value_lower in ("true", "1", "yes", "on"):
35
+ return True
36
+ elif value_lower in ("false", "0", "no", "off", ""):
37
+ return False
38
+ return default
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class ExecutionContext:
43
+ """
44
+ Execution context - all configuration for a single execution.
45
+
46
+ This class is immutable (frozen=True). To modify, use with_override()
47
+ which returns a new instance.
48
+
49
+ Attributes:
50
+ sandbox_level: Sandbox security level ("1", "2", or "3")
51
+ allow_network: Whether to allow network access
52
+ timeout: Execution timeout in seconds
53
+ max_memory_mb: Maximum memory limit in MB
54
+ auto_approve: Whether to auto-approve security prompts
55
+ confirmed: Whether user has confirmed execution (for security flow)
56
+ scan_id: Scan ID from security scan (for verification)
57
+ requires_elevated: Whether skill requires elevated permissions
58
+ """
59
+ sandbox_level: str = DEFAULT_SANDBOX_LEVEL
60
+ allow_network: bool = DEFAULT_ALLOW_NETWORK
61
+ timeout: int = DEFAULT_TIMEOUT
62
+ max_memory_mb: int = DEFAULT_MAX_MEMORY_MB
63
+ auto_approve: bool = DEFAULT_AUTO_APPROVE
64
+ confirmed: bool = False
65
+ scan_id: Optional[str] = None
66
+ requires_elevated: bool = False
67
+
68
+ @classmethod
69
+ def from_current_env(cls) -> "ExecutionContext":
70
+ """
71
+ Create context from current environment variables.
72
+
73
+ This method reads from environment variables at call time,
74
+ ensuring the latest values are used.
75
+
76
+ Environment Variables:
77
+ SKILLBOX_SANDBOX_LEVEL: Sandbox level (1/2/3, default: 3)
78
+ SKILLBOX_ALLOW_NETWORK: Allow network access (true/false)
79
+ SKILLBOX_TIMEOUT_SECS: Execution timeout in seconds
80
+ SKILLBOX_MAX_MEMORY_MB: Maximum memory in MB
81
+ SKILLBOX_AUTO_APPROVE: Auto-approve security prompts
82
+ """
83
+ return cls(
84
+ sandbox_level=os.environ.get("SKILLBOX_SANDBOX_LEVEL", DEFAULT_SANDBOX_LEVEL),
85
+ allow_network=_parse_bool_env("SKILLBOX_ALLOW_NETWORK", DEFAULT_ALLOW_NETWORK),
86
+ timeout=int(os.environ.get("SKILLBOX_TIMEOUT_SECS", str(DEFAULT_TIMEOUT))),
87
+ max_memory_mb=int(os.environ.get("SKILLBOX_MAX_MEMORY_MB", str(DEFAULT_MAX_MEMORY_MB))),
88
+ auto_approve=_parse_bool_env("SKILLBOX_AUTO_APPROVE", DEFAULT_AUTO_APPROVE),
89
+ )
90
+
91
+ def with_override(
92
+ self,
93
+ sandbox_level: Optional[str] = None,
94
+ allow_network: Optional[bool] = None,
95
+ timeout: Optional[int] = None,
96
+ max_memory_mb: Optional[int] = None,
97
+ auto_approve: Optional[bool] = None,
98
+ confirmed: bool = False,
99
+ scan_id: Optional[str] = None,
100
+ requires_elevated: Optional[bool] = None,
101
+ ) -> "ExecutionContext":
102
+ """
103
+ Create a new context with specified overrides.
104
+
105
+ Args:
106
+ sandbox_level: Override sandbox level
107
+ allow_network: Override network setting
108
+ timeout: Override timeout
109
+ max_memory_mb: Override memory limit
110
+ auto_approve: Override auto-approve setting
111
+ confirmed: Set confirmed flag
112
+ scan_id: Set scan ID
113
+ requires_elevated: Set requires elevated flag
114
+
115
+ Returns:
116
+ New ExecutionContext with overrides applied
117
+ """
118
+ return ExecutionContext(
119
+ sandbox_level=sandbox_level if sandbox_level is not None else self.sandbox_level,
120
+ allow_network=allow_network if allow_network is not None else self.allow_network,
121
+ timeout=timeout if timeout is not None else self.timeout,
122
+ max_memory_mb=max_memory_mb if max_memory_mb is not None else self.max_memory_mb,
123
+ auto_approve=auto_approve if auto_approve is not None else self.auto_approve,
124
+ confirmed=confirmed if confirmed else self.confirmed,
125
+ scan_id=scan_id if scan_id is not None else self.scan_id,
126
+ requires_elevated=requires_elevated if requires_elevated is not None else self.requires_elevated,
127
+ )
128
+
129
+ def with_user_confirmation(self, scan_id: str) -> "ExecutionContext":
130
+ """
131
+ Create a new context after user confirmation.
132
+
133
+ This downgrades sandbox level to 1 (no sandbox) since user has approved.
134
+ """
135
+ return self.with_override(
136
+ sandbox_level="1",
137
+ confirmed=True,
138
+ scan_id=scan_id,
139
+ )
140
+
141
+ def with_elevated_permissions(self) -> "ExecutionContext":
142
+ """
143
+ Create a new context with elevated permissions.
144
+
145
+ This downgrades sandbox level to 1 for skills that require
146
+ elevated permissions (e.g., skill-creator).
147
+ """
148
+ return self.with_override(
149
+ sandbox_level="1",
150
+ requires_elevated=True,
151
+ )
152
+
153
+
154
+ __all__ = ["ExecutionContext", "DEFAULT_SANDBOX_LEVEL", "DEFAULT_TIMEOUT", "DEFAULT_MAX_MEMORY_MB"]
155
+
@@ -0,0 +1,254 @@
1
+ """
2
+ Unified Execution Service - High-level execution service for all entry points.
3
+
4
+ This module provides the UnifiedExecutionService class which is the single entry point
5
+ for all skill execution. It integrates security scanning, user confirmation, and
6
+ execution into a unified flow.
7
+
8
+ All entry points (AgenticLoop, LangChain, LlamaIndex, MCP) should use this service.
9
+
10
+ Key Features:
11
+ 1. Unified security scanning
12
+ 2. Unified confirmation flow
13
+ 3. Unified execution
14
+ 4. Context management
15
+ 5. Temporary context overrides
16
+ """
17
+
18
+ import os
19
+ from contextlib import contextmanager
20
+ from pathlib import Path
21
+ from typing import Any, Callable, Dict, Optional, TYPE_CHECKING
22
+
23
+ from .base import ExecutionResult
24
+ from .context import ExecutionContext
25
+ from .unified_executor import UnifiedExecutor
26
+
27
+ if TYPE_CHECKING:
28
+ from ..core.skill_info import SkillInfo
29
+
30
+
31
+ # Type alias for confirmation callback
32
+ ConfirmationCallback = Callable[[str, str], bool]
33
+
34
+
35
+ class UnifiedExecutionService:
36
+ """
37
+ Unified execution service - single entry point for all skill execution.
38
+
39
+ This service integrates:
40
+ 1. Security scanning (via UnifiedSecurityScanner)
41
+ 2. User confirmation flow
42
+ 3. Skill execution (via UnifiedExecutor)
43
+
44
+ Usage:
45
+ service = UnifiedExecutionService.get_instance()
46
+ result = service.execute_skill(skill_info, input_data, confirmation_callback)
47
+ """
48
+
49
+ _instance: Optional["UnifiedExecutionService"] = None
50
+
51
+ @classmethod
52
+ def get_instance(cls) -> "UnifiedExecutionService":
53
+ """Get singleton instance of the service."""
54
+ if cls._instance is None:
55
+ cls._instance = cls()
56
+ return cls._instance
57
+
58
+ @classmethod
59
+ def reset_instance(cls) -> None:
60
+ """Reset singleton instance (for testing)."""
61
+ cls._instance = None
62
+
63
+ def __init__(self):
64
+ """Initialize the service."""
65
+ self._executor = UnifiedExecutor()
66
+ self._scanner = None # Lazy initialization
67
+
68
+ def _get_scanner(self):
69
+ """Get security scanner (lazy initialization)."""
70
+ if self._scanner is None:
71
+ from ..core.security import SecurityScanner
72
+ self._scanner = SecurityScanner()
73
+ return self._scanner
74
+
75
+ def execute_skill(
76
+ self,
77
+ skill_info: "SkillInfo",
78
+ input_data: Dict[str, Any],
79
+ entry_point: Optional[str] = None,
80
+ confirmation_callback: Optional[ConfirmationCallback] = None,
81
+ allow_network: Optional[bool] = None,
82
+ timeout: Optional[int] = None,
83
+ ) -> ExecutionResult:
84
+ """
85
+ Execute a skill with unified security and confirmation flow.
86
+
87
+ Flow:
88
+ 1. Read current execution context from environment
89
+ 2. Check if skill requires elevated permissions
90
+ 3. Perform security scan (if Level 3)
91
+ 4. Request user confirmation (if high-severity issues)
92
+ 5. Adjust context based on confirmation
93
+ 6. Execute skill
94
+
95
+ Args:
96
+ skill_info: SkillInfo object with skill metadata
97
+ input_data: Input data for the skill
98
+ entry_point: Optional specific script to execute
99
+ confirmation_callback: Callback for security confirmation
100
+ allow_network: Override network setting
101
+ timeout: Override timeout setting
102
+
103
+ Returns:
104
+ ExecutionResult with output or error
105
+ """
106
+ # 1. Read current context from environment
107
+ context = ExecutionContext.from_current_env()
108
+
109
+ # 2. Apply overrides
110
+ if allow_network is not None or timeout is not None:
111
+ context = context.with_override(
112
+ allow_network=allow_network,
113
+ timeout=timeout,
114
+ )
115
+
116
+ # 3. Check if skill requires elevated permissions
117
+ requires_elevated = self._requires_elevated_permissions(skill_info)
118
+ if requires_elevated:
119
+ context = context.with_elevated_permissions()
120
+
121
+ # 4. Security scan and confirmation (Level 3 only)
122
+ if context.sandbox_level == "3":
123
+ scan_result = self._perform_security_scan(skill_info, input_data)
124
+
125
+ if scan_result and scan_result.requires_confirmation:
126
+ if confirmation_callback:
127
+ report = scan_result.format_report()
128
+ confirmed = confirmation_callback(report, scan_result.scan_id)
129
+
130
+ if confirmed:
131
+ # User confirmed -> downgrade to Level 1
132
+ context = context.with_user_confirmation(scan_result.scan_id)
133
+ else:
134
+ return ExecutionResult(
135
+ success=False,
136
+ error="Execution cancelled by user after security review",
137
+ exit_code=1,
138
+ )
139
+ else:
140
+ # No callback, return security report
141
+ return ExecutionResult(
142
+ success=False,
143
+ error=f"Security confirmation required:\n{scan_result.format_report()}",
144
+ exit_code=2,
145
+ )
146
+
147
+ # 5. Execute skill
148
+ return self._executor.execute(
149
+ context=context,
150
+ skill_dir=skill_info.path,
151
+ input_data=input_data,
152
+ entry_point=entry_point,
153
+ )
154
+
155
+ def execute_with_context(
156
+ self,
157
+ context: ExecutionContext,
158
+ skill_dir: Path,
159
+ input_data: Dict[str, Any],
160
+ entry_point: Optional[str] = None,
161
+ args: Optional[list] = None,
162
+ ) -> ExecutionResult:
163
+ """
164
+ Execute with explicit context (bypasses security scan).
165
+
166
+ Use this when you've already performed security checks
167
+ and have a prepared context.
168
+
169
+ Args:
170
+ context: Pre-configured execution context
171
+ skill_dir: Path to skill directory
172
+ input_data: Input data for the skill
173
+ entry_point: Optional specific script to execute
174
+ args: Optional command line arguments
175
+
176
+ Returns:
177
+ ExecutionResult with output or error
178
+ """
179
+ return self._executor.execute(
180
+ context=context,
181
+ skill_dir=skill_dir,
182
+ input_data=input_data,
183
+ entry_point=entry_point,
184
+ args=args,
185
+ )
186
+
187
+ def _requires_elevated_permissions(self, skill_info: "SkillInfo") -> bool:
188
+ """Check if skill requires elevated permissions."""
189
+ if skill_info.metadata:
190
+ return getattr(skill_info.metadata, 'requires_elevated_permissions', False)
191
+ return False
192
+
193
+ def _perform_security_scan(
194
+ self,
195
+ skill_info: "SkillInfo",
196
+ input_data: Dict[str, Any],
197
+ ):
198
+ """Perform security scan on skill."""
199
+ try:
200
+ scanner = self._get_scanner()
201
+ return scanner.scan_skill(skill_info, input_data)
202
+ except Exception:
203
+ return None
204
+
205
+ @contextmanager
206
+ def temporary_context(
207
+ self,
208
+ sandbox_level: Optional[str] = None,
209
+ allow_network: Optional[bool] = None,
210
+ ):
211
+ """
212
+ Context manager for temporary execution context changes.
213
+
214
+ This temporarily modifies environment variables and restores
215
+ them when the context exits.
216
+
217
+ Usage:
218
+ with service.temporary_context(sandbox_level="1"):
219
+ result = service.execute_skill(...)
220
+
221
+ Args:
222
+ sandbox_level: Temporary sandbox level
223
+ allow_network: Temporary network setting
224
+ """
225
+ old_sandbox_level = os.environ.get("SKILLBOX_SANDBOX_LEVEL")
226
+ old_allow_network = os.environ.get("SKILLBOX_ALLOW_NETWORK")
227
+
228
+ try:
229
+ if sandbox_level is not None:
230
+ os.environ["SKILLBOX_SANDBOX_LEVEL"] = sandbox_level
231
+ if allow_network is not None:
232
+ os.environ["SKILLBOX_ALLOW_NETWORK"] = "true" if allow_network else "false"
233
+ yield
234
+ finally:
235
+ # Restore original values
236
+ if sandbox_level is not None:
237
+ if old_sandbox_level is not None:
238
+ os.environ["SKILLBOX_SANDBOX_LEVEL"] = old_sandbox_level
239
+ elif "SKILLBOX_SANDBOX_LEVEL" in os.environ:
240
+ del os.environ["SKILLBOX_SANDBOX_LEVEL"]
241
+
242
+ if allow_network is not None:
243
+ if old_allow_network is not None:
244
+ os.environ["SKILLBOX_ALLOW_NETWORK"] = old_allow_network
245
+ elif "SKILLBOX_ALLOW_NETWORK" in os.environ:
246
+ del os.environ["SKILLBOX_ALLOW_NETWORK"]
247
+
248
+ def get_current_context(self) -> ExecutionContext:
249
+ """Get current execution context from environment."""
250
+ return ExecutionContext.from_current_env()
251
+
252
+
253
+ __all__ = ["UnifiedExecutionService", "ConfirmationCallback"]
254
+
@@ -156,14 +156,18 @@ class SkillboxExecutor(SandboxExecutor):
156
156
  def _build_skill_env(self, skill_dir: Path, timeout: Optional[int] = None) -> Dict[str, str]:
157
157
  """
158
158
  Build environment variables for skill execution.
159
-
159
+
160
160
  Args:
161
161
  skill_dir: Path to the skill directory
162
162
  timeout: Optional timeout override
163
-
163
+
164
164
  Returns:
165
165
  Environment dictionary
166
166
  """
167
+ # Allow runtime override of sandbox level via environment variable
168
+ # This enables Python-layer security confirmation to skip skillbox confirmation
169
+ effective_sandbox_level = os.environ.get("SKILLBOX_SANDBOX_LEVEL", self.sandbox_level)
170
+
167
171
  return {
168
172
  **os.environ,
169
173
  "PYTHONUNBUFFERED": "1",
@@ -171,12 +175,81 @@ class SkillboxExecutor(SandboxExecutor):
171
175
  "SKILL_ASSETS_DIR": str(skill_dir / "assets"),
172
176
  "SKILL_REFERENCES_DIR": str(skill_dir / "references"),
173
177
  "SKILL_SCRIPTS_DIR": str(skill_dir / "scripts"),
174
- "SKILLBOX_SANDBOX_LEVEL": self.sandbox_level,
178
+ "SKILLBOX_SANDBOX_LEVEL": effective_sandbox_level,
175
179
  "SKILLBOX_MAX_MEMORY_MB": str(self.max_memory_mb),
176
180
  "SKILLBOX_TIMEOUT_SECS": str(timeout if timeout is not None else self.execution_timeout),
177
181
  "SKILLBOX_AUTO_APPROVE": os.environ.get("SKILLBOX_AUTO_APPROVE", ""),
178
182
  }
179
-
183
+
184
+ def _format_sandbox_error(self, error_msg: str) -> str:
185
+ """
186
+ Format sandbox restriction errors into user-friendly messages.
187
+
188
+ Args:
189
+ error_msg: Raw error message from subprocess
190
+
191
+ Returns:
192
+ Formatted error message
193
+ """
194
+ # Check for common sandbox restriction patterns
195
+ sandbox_errors = {
196
+ "BlockingIOError": "🔒 Sandbox blocked process creation (fork/exec not allowed)",
197
+ "Resource temporarily unavailable": "🔒 Sandbox blocked system resource access",
198
+ "Operation not permitted": "🔒 Sandbox blocked this operation",
199
+ "Permission denied": "🔒 Sandbox denied file/resource access",
200
+ "sandbox-exec": "🔒 Sandbox restriction triggered",
201
+ }
202
+
203
+ for pattern, friendly_msg in sandbox_errors.items():
204
+ if pattern in error_msg:
205
+ # Return only the friendly message, hide the traceback
206
+ return f"{friendly_msg}\n\n💡 This skill requires operations that are blocked by the sandbox for security reasons."
207
+
208
+ return error_msg
209
+
210
+ def _extract_json_from_output(self, output: str) -> Optional[Any]:
211
+ """
212
+ Try to extract JSON from skillbox output that may contain log lines.
213
+
214
+ Skillbox output format may include:
215
+ - [INFO] ... log lines
216
+ - [WARN] ... log lines
217
+ - Error: ... messages
218
+ - Then the actual JSON object
219
+
220
+ Args:
221
+ output: Raw output from skillbox
222
+
223
+ Returns:
224
+ Parsed JSON object if found, None otherwise
225
+ """
226
+ if not output:
227
+ return None
228
+
229
+ # First try: parse entire output as JSON
230
+ try:
231
+ return json.loads(output.strip())
232
+ except json.JSONDecodeError:
233
+ pass
234
+
235
+ # Second try: find JSON object by looking for { and matching }
236
+ # This handles cases where JSON contains newlines (like \n in strings)
237
+ brace_start = output.rfind('{')
238
+ if brace_start == -1:
239
+ return None
240
+
241
+ brace_end = output.rfind('}')
242
+ if brace_end == -1 or brace_end < brace_start:
243
+ return None
244
+
245
+ json_str = output[brace_start:brace_end + 1]
246
+ try:
247
+ return json.loads(json_str)
248
+ except json.JSONDecodeError:
249
+ pass
250
+
251
+ return None
252
+
180
253
  def _parse_output(self, stdout: str, stderr: str, returncode: int) -> ExecutionResult:
181
254
  """
182
255
  Parse subprocess output into ExecutionResult.
@@ -209,9 +282,13 @@ class SkillboxExecutor(SandboxExecutor):
209
282
  stderr=stderr
210
283
  )
211
284
  else:
285
+ # Check for sandbox restriction errors and provide friendly messages
286
+ error_msg = stderr or stdout or f"Exit code: {returncode}"
287
+ error_msg = self._format_sandbox_error(error_msg)
288
+
212
289
  return ExecutionResult(
213
290
  success=False,
214
- error=stderr or f"Exit code: {returncode}",
291
+ error=error_msg,
215
292
  exit_code=returncode,
216
293
  stdout=stdout,
217
294
  stderr=stderr
@@ -284,10 +361,12 @@ class SkillboxExecutor(SandboxExecutor):
284
361
 
285
362
  if allow_network:
286
363
  cmd.append("--allow-network")
287
-
288
- if enable_sandbox:
289
- cmd.append("--enable-sandbox")
290
-
364
+
365
+ # Use --sandbox-level instead of --enable-sandbox
366
+ # sandbox_level: 1=no sandbox, 2=sandbox only, 3=sandbox+scan
367
+ if self.sandbox_level:
368
+ cmd.extend(["--sandbox-level", str(self.sandbox_level)])
369
+
291
370
  if self.cache_dir:
292
371
  cmd.extend(["--cache-dir", self.cache_dir])
293
372
 
@@ -537,20 +616,25 @@ class SkillboxExecutor(SandboxExecutor):
537
616
  cmd.extend(["--cache-dir", self.cache_dir])
538
617
 
539
618
  skill_env = self._build_skill_env(skill_dir, timeout)
540
-
619
+
620
+ # Get effective sandbox level (may be overridden by environment variable)
621
+ effective_sandbox_level = skill_env.get("SKILLBOX_SANDBOX_LEVEL", self.sandbox_level)
622
+
541
623
  # Execute with Level 3 user interaction support
542
624
  try:
543
- if self.sandbox_level == "3":
625
+ if effective_sandbox_level == "3":
626
+ # Level 3: Allow user interaction for authorization prompts
544
627
  result = subprocess.run(
545
628
  cmd,
546
629
  stdin=None,
547
630
  stdout=subprocess.PIPE,
548
- stderr=None,
631
+ stderr=None, # Let stderr flow to terminal for authorization prompts
549
632
  text=True,
550
633
  timeout=effective_timeout,
551
634
  env=skill_env
552
635
  )
553
636
  else:
637
+ # Level 1/2: Capture all output
554
638
  result = subprocess.run(
555
639
  cmd,
556
640
  capture_output=True,
@@ -560,10 +644,14 @@ class SkillboxExecutor(SandboxExecutor):
560
644
  )
561
645
 
562
646
  stderr = result.stderr if hasattr(result, 'stderr') and result.stderr else ""
563
-
647
+
648
+ # Combine stdout and stderr for JSON extraction
649
+ # skillbox may output JSON to either stream
650
+ combined_output = result.stdout + stderr
651
+
564
652
  if result.returncode == 0:
565
- try:
566
- output = json.loads(result.stdout.strip())
653
+ output = self._extract_json_from_output(combined_output)
654
+ if output is not None:
567
655
  return ExecutionResult(
568
656
  success=True,
569
657
  output=output,
@@ -571,18 +659,35 @@ class SkillboxExecutor(SandboxExecutor):
571
659
  stdout=result.stdout,
572
660
  stderr=stderr
573
661
  )
574
- except json.JSONDecodeError as e:
662
+ else:
663
+ # No JSON found, return raw output as success
575
664
  return ExecutionResult(
576
- success=False,
577
- error=f"Invalid JSON output: {e}",
665
+ success=True,
666
+ output={"raw_output": result.stdout.strip()},
578
667
  exit_code=result.returncode,
579
668
  stdout=result.stdout,
580
669
  stderr=stderr
581
670
  )
582
671
  else:
672
+ # Check if there's valid JSON in combined output (skillbox may still output results)
673
+ output = self._extract_json_from_output(combined_output)
674
+ if output is not None:
675
+ # If we found valid JSON with exit_code=0, treat as success
676
+ if isinstance(output, dict) and output.get("exit_code") == 0:
677
+ return ExecutionResult(
678
+ success=True,
679
+ output=output,
680
+ exit_code=0,
681
+ stdout=result.stdout,
682
+ stderr=stderr
683
+ )
684
+
685
+ error_msg = stderr or result.stdout or f"Exit code: {result.returncode}"
686
+ error_msg = self._format_sandbox_error(error_msg)
687
+
583
688
  return ExecutionResult(
584
689
  success=False,
585
- error=stderr or f"Exit code: {result.returncode}",
690
+ error=error_msg,
586
691
  exit_code=result.returncode,
587
692
  stdout=result.stdout,
588
693
  stderr=stderr