codeshield-runtime 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.
codeshield/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ """CodeShield.
2
+
3
+ A secure, isolated, and self-healing Python code execution engine.
4
+ """
5
+
6
+ __version__ = "0.1.0"
7
+
8
+ from codeshield.loop import SelfHealingEngine
9
+ from codeshield.schemas import CodeExecutionRequest, ExecutionResult
10
+ from codeshield.tools import create_code_execution_tool
11
+
12
+ __all__ = [
13
+ "SelfHealingEngine",
14
+ "CodeExecutionRequest",
15
+ "ExecutionResult",
16
+ "create_code_execution_tool",
17
+ ]
codeshield/__main__.py ADDED
@@ -0,0 +1,10 @@
1
+ """Entry point for `python -m codeshield`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from codeshield.cli import main
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main())
codeshield/analyzer.py ADDED
@@ -0,0 +1,151 @@
1
+ """Static syntax and safety analysis using only the standard ``ast`` module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import logging
7
+ from dataclasses import dataclass, field
8
+
9
+ from codeshield.schemas import ValidationReport
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ # Parametrizable functions considered dangerous when invoked directly.
15
+ _DANGEROUS_NAMES: frozenset[str] = frozenset({
16
+ "eval",
17
+ "exec",
18
+ "compile",
19
+ "os.system",
20
+ "subprocess.call",
21
+ "subprocess.run",
22
+ "subprocess.Popen",
23
+ })
24
+ _GENERIC_EXCEPTIONS: frozenset[str] = frozenset({"Exception", "BaseException"})
25
+
26
+
27
+ @dataclass
28
+ class _SafetyContext:
29
+ """Mutable collector used by the AST visitor."""
30
+
31
+ violations: list[str] = field(default_factory=list)
32
+ line_numbers: dict[int, str] = field(default_factory=dict)
33
+
34
+ def add_violation(self, message: str, *, line: int | None = None) -> None:
35
+ """Append a violation with an optional source line hint."""
36
+ if line is not None:
37
+ message = f"Line {line}: {message}"
38
+ self.violations.append(message)
39
+
40
+
41
+ class _SafetyVisitor(ast.NodeVisitor):
42
+ """AST visitor that flags generic exception handlers and dangerous calls."""
43
+
44
+ def __init__(self, context: _SafetyContext) -> None:
45
+ self._context = context
46
+
47
+ def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None: # noqa: N802
48
+ """Detect overly broad exception handlers."""
49
+ self._check_generic_except(node)
50
+ self.generic_visit(node)
51
+
52
+ def visit_Call(self, node: ast.Call) -> None: # noqa: N802
53
+ """Detect dangerous parametrizable function calls."""
54
+ self._check_dangerous_call(node)
55
+ self.generic_visit(node)
56
+
57
+ def _check_generic_except(self, node: ast.ExceptHandler) -> None:
58
+ """Flag ``except:``, ``except Exception:`` and ``except BaseException:``."""
59
+ if node.type is None:
60
+ self._context.add_violation(
61
+ "Bare ``except:`` clause is not allowed because it catches all exceptions",
62
+ line=node.lineno,
63
+ )
64
+ return
65
+
66
+ name = self._name_from_node(node.type)
67
+ if name in _GENERIC_EXCEPTIONS:
68
+ self._context.add_violation(
69
+ f"Overly generic exception handler ``except {name}:`` is not allowed",
70
+ line=node.lineno,
71
+ )
72
+
73
+ def _check_dangerous_call(self, node: ast.Call) -> None:
74
+ """Flag direct calls to eval/exec/compile."""
75
+ name = self._name_from_node(node.func)
76
+ if name in _DANGEROUS_NAMES:
77
+ self._context.add_violation(
78
+ f"Dangerous parametrizable function call ``{name}()`` is not allowed",
79
+ line=node.lineno,
80
+ )
81
+
82
+ @staticmethod
83
+ def _name_from_node(node: ast.AST | None) -> str:
84
+ """Return a best-effort dotted name for an AST node."""
85
+ if node is None:
86
+ return ""
87
+ if isinstance(node, ast.Name):
88
+ return node.id
89
+ if isinstance(node, ast.Attribute):
90
+ return f"{_SafetyVisitor._name_from_node(node.value)}.{node.attr}"
91
+ if isinstance(node, ast.Subscript):
92
+ return _SafetyVisitor._name_from_node(node.value)
93
+ return ""
94
+
95
+
96
+ def _lines_from_source(code: str) -> dict[int, str]:
97
+ """Map 1-indexed line numbers to stripped source lines."""
98
+ return {idx + 1: line for idx, line in enumerate(code.splitlines())}
99
+
100
+
101
+ def _split_first_line(code: str) -> tuple[str, ...]:
102
+ """Return source lines as a tuple to keep them hashable when needed."""
103
+ return tuple(code.splitlines())
104
+
105
+
106
+ def validate_syntax_and_safety(code: str) -> ValidationReport:
107
+ """Validate ``code`` using the standard ``ast`` module.
108
+
109
+ The validation detects:
110
+ - Syntax errors raised by ``ast.parse``.
111
+ - Bare ``except:`` clauses and handlers using ``Exception``/``BaseException``.
112
+ - Direct calls to dangerous parametrizable functions: ``eval``, ``exec`` and ``compile``.
113
+
114
+ Args:
115
+ code: Python source code to validate.
116
+
117
+ Returns:
118
+ A ``ValidationReport`` containing the result and any violations.
119
+ """
120
+ lines = _lines_from_source(code)
121
+ try:
122
+ tree = ast.parse(code, filename="<dynamic>", mode="exec")
123
+ except SyntaxError as exc:
124
+ location = f"line {exc.lineno}" if exc.lineno else "unknown location"
125
+ text = lines.get(exc.lineno, "") if exc.lineno else ""
126
+ pointer = f"\n{text}\n{' ' * ((exc.offset or 1) - 1)}^" if exc.offset and text else ""
127
+ message = f"SyntaxError at {location}: {exc.msg}{pointer}"
128
+ logger.debug("Syntax validation failed: %s", message)
129
+ return ValidationReport(
130
+ is_valid=False,
131
+ violations=[message],
132
+ exception=exc,
133
+ )
134
+
135
+ context = _SafetyContext(line_numbers=lines)
136
+ visitor = _SafetyVisitor(context)
137
+ visitor.visit(tree)
138
+
139
+ is_valid = not context.violations
140
+ logger.debug(
141
+ "Safety validation completed: %d violation(s) found.",
142
+ len(context.violations),
143
+ )
144
+ return ValidationReport(
145
+ is_valid=is_valid,
146
+ violations=context.violations,
147
+ exception=None,
148
+ )
149
+
150
+
151
+ __all__: list[str] = ["validate_syntax_and_safety"]
@@ -0,0 +1,163 @@
1
+ """Traceback parsing and error classification utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import re
7
+
8
+ from codeshield.schemas import ErrorDiagnosis
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class TracebackClassifier:
14
+ """Extract structured diagnostics from Python tracebacks."""
15
+
16
+ def __init__(self, context_radius: int = 2) -> None:
17
+ """Initialize the classifier.
18
+
19
+ Args:
20
+ context_radius: Number of source lines to include before and after
21
+ the failing line.
22
+ """
23
+ self._context_radius = max(0, context_radius)
24
+
25
+ def classify(self, code: str, stderr: str) -> ErrorDiagnosis | None:
26
+ """Extract an ``ErrorDiagnosis`` from ``stderr``.
27
+
28
+ Args:
29
+ code: Original source code used to provide context.
30
+ stderr: Standard error produced by the executed process.
31
+
32
+ Returns:
33
+ An ``ErrorDiagnosis`` if a traceback could be parsed, otherwise ``None``.
34
+ """
35
+ if not stderr.strip():
36
+ return None
37
+
38
+ if "Traceback" not in stderr:
39
+ return None
40
+
41
+ exception_match = self._extract_exception_line(stderr)
42
+ if not exception_match:
43
+ return None
44
+
45
+ exc_type, exc_message = exception_match
46
+ line_no = self._extract_failing_line(stderr, code)
47
+ context = self._extract_context(code, line_no)
48
+
49
+ logger.debug(
50
+ "Classified %s at line %s: %s",
51
+ exc_type,
52
+ line_no,
53
+ exc_message,
54
+ )
55
+ return ErrorDiagnosis(
56
+ error_type=exc_type,
57
+ root_cause_line=line_no,
58
+ message=exc_message,
59
+ context=context,
60
+ )
61
+
62
+ def classify_from_result(self, code: str, stderr: str) -> ErrorDiagnosis:
63
+ """Return a diagnosis, falling back to a generic one when parsing fails."""
64
+ diagnosis = self.classify(code, stderr)
65
+ if diagnosis is not None:
66
+ return diagnosis
67
+
68
+ return ErrorDiagnosis(
69
+ error_type="UnknownError",
70
+ root_cause_line=None,
71
+ message=stderr.strip() or "An unknown error occurred during execution.",
72
+ context=[],
73
+ )
74
+
75
+ def _extract_exception_line(self, stderr: str) -> tuple[str, str] | None:
76
+ """Parse the final traceback line ``ExceptionName: message``.
77
+
78
+ Returns:
79
+ A tuple ``(exception_name, message)`` or ``None``.
80
+ """
81
+ # Find the last line starting after the traceback header that matches
82
+ # an exception declaration. We scan all lines and keep the last valid one.
83
+ in_traceback = False
84
+ last_match: tuple[str, str] | None = None
85
+
86
+ for raw_line in stderr.splitlines():
87
+ line = raw_line.rstrip()
88
+ if in_traceback:
89
+ pattern = r"^([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*):\s*(.*)$"
90
+ match = re.match(pattern, line)
91
+ if match:
92
+ exception_name = match.group(1)
93
+ message = match.group(2).strip()
94
+ # Ignore built-in notes that follow the exception line.
95
+ last_match = (exception_name, message)
96
+ if line.startswith("Traceback"):
97
+ in_traceback = True
98
+
99
+ return last_match
100
+
101
+ def _extract_failing_line(self, stderr: str, code: str) -> int | None:
102
+ """Determine the most relevant failing source line from the traceback.
103
+
104
+ The method looks for the deepest file in the executed code, and if the
105
+ script is not external, returns its line number. If only external frames
106
+ are available, the last line number is returned as a fallback.
107
+ """
108
+ # Pattern: File "...", line X, in ...
109
+ pattern = re.compile(r'File "([^"]+)", line (\d+), in (.+)')
110
+ lines = code.splitlines()
111
+
112
+ last_line: int | None = None
113
+ for raw in reversed(stderr.splitlines()):
114
+ match = pattern.search(raw)
115
+ if not match:
116
+ continue
117
+ file_path, line_no_str, _ = match.groups()
118
+ line_no = int(line_no_str)
119
+ last_line = line_no
120
+
121
+ # If the frame references the inline script, use it directly.
122
+ if "<string>" in file_path or file_path.endswith("script.py"):
123
+ return line_no
124
+
125
+ # Heuristic: line number is inside the provided source range.
126
+ if 1 <= line_no <= len(lines):
127
+ return line_no
128
+
129
+ return last_line
130
+
131
+ def _extract_context(self, code: str, line_no: int | None) -> list[str]:
132
+ """Return surrounding source lines for ``line_no``."""
133
+ if line_no is None:
134
+ return []
135
+
136
+ lines = code.splitlines()
137
+ if not lines:
138
+ return []
139
+
140
+ start = max(1, line_no - self._context_radius)
141
+ end = min(len(lines), line_no + self._context_radius)
142
+ return [f"{idx}: {lines[idx - 1]}" for idx in range(start, end + 1)]
143
+
144
+ def extract_missing_name(self, diagnosis: ErrorDiagnosis) -> str | None:
145
+ """For ``NameError``, try to extract the missing identifier."""
146
+ if diagnosis.error_type != "NameError":
147
+ return None
148
+ match = re.search(r"name '([^']+)' is not defined", diagnosis.message)
149
+ if match:
150
+ return match.group(1)
151
+ return None
152
+
153
+ def extract_missing_module(self, diagnosis: ErrorDiagnosis) -> str | None:
154
+ """For ``ImportError``/``ModuleNotFoundError``, try to extract the module name."""
155
+ if diagnosis.error_type not in {"ImportError", "ModuleNotFoundError"}:
156
+ return None
157
+ match = re.search(r"No module named '([^']+)'", diagnosis.message)
158
+ if match:
159
+ return match.group(1)
160
+ return None
161
+
162
+
163
+ __all__: list[str] = ["TracebackClassifier"]
codeshield/cli.py ADDED
@@ -0,0 +1,101 @@
1
+ """Command-line interface for the execution engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import logging
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from codeshield.loop import SelfHealingEngine
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ def _build_parser() -> argparse.ArgumentParser:
16
+ """Return the argument parser for the CLI."""
17
+ parser = argparse.ArgumentParser(
18
+ prog="codeshield",
19
+ description="Deterministic, isolated, and self-healing Python code execution.",
20
+ )
21
+ subparsers = parser.add_subparsers(dest="command", required=True)
22
+
23
+ run_parser = subparsers.add_parser(
24
+ "run",
25
+ help="Execute a Python source file inside a sandbox.",
26
+ )
27
+ run_parser.add_argument(
28
+ "file",
29
+ type=Path,
30
+ help="Path to the Python file to execute.",
31
+ )
32
+ run_parser.add_argument(
33
+ "--timeout",
34
+ type=float,
35
+ default=60.0,
36
+ help="Execution timeout in seconds (default: 60).",
37
+ )
38
+ run_parser.add_argument(
39
+ "--llm",
40
+ action="store_true",
41
+ default=True,
42
+ help="Enable LLM-guided self-healing when GEMINI_API_KEY is set (default: on).",
43
+ )
44
+ run_parser.add_argument(
45
+ "--no-llm",
46
+ action="store_true",
47
+ help="Disable LLM-guided self-healing and use the local fallback.",
48
+ )
49
+
50
+ return parser
51
+
52
+
53
+ def _read_file(file_path: Path) -> str:
54
+ """Read and return the contents of ``file_path``."""
55
+ try:
56
+ return file_path.read_text(encoding="utf-8")
57
+ except OSError as exc:
58
+ logger.error("Could not read %s: %s", file_path, exc)
59
+ raise SystemExit(1) from exc
60
+
61
+
62
+ def main(argv: list[str] | None = None) -> int:
63
+ """Entry point for the CLI."""
64
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
65
+
66
+ parser = _build_parser()
67
+ args = parser.parse_args(argv)
68
+
69
+ if args.command != "run":
70
+ parser.print_help()
71
+ return 2
72
+
73
+ if not args.file.exists():
74
+ logger.error("File not found: %s", args.file)
75
+ return 1
76
+
77
+ code = _read_file(args.file)
78
+ use_llm = not args.no_llm if args.no_llm else args.llm
79
+
80
+ engine = SelfHealingEngine(use_llm=use_llm)
81
+ with engine:
82
+ result, diagnosis = engine.run(code, timeout=args.timeout)
83
+
84
+ print("--- STDOUT ---")
85
+ print(result.stdout)
86
+ if result.stderr:
87
+ print("--- STDERR ---")
88
+ print(result.stderr)
89
+
90
+ if result.timed_out:
91
+ print("Execution timed out")
92
+ if result.silent_failure_detected:
93
+ print("Silent failure detected")
94
+ if diagnosis:
95
+ print(f"Diagnosis: {diagnosis.error_type} - {diagnosis.message}")
96
+
97
+ return 0 if result.exit_code == 0 and not result.silent_failure_detected else 1
98
+
99
+
100
+ if __name__ == "__main__":
101
+ sys.exit(main())
@@ -0,0 +1,256 @@
1
+ """Management of isolated ``uv`` virtual environments used as sandboxes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ import tempfile
10
+ from collections.abc import Sequence
11
+ from pathlib import Path
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class SandboxError(RuntimeError):
17
+ """Raised when a sandbox cannot be created, destroyed or updated."""
18
+
19
+
20
+ class SandboxManager:
21
+ """Create, populate and clean up an isolated virtual environment.
22
+
23
+ The manager prefers the ``uv`` toolchain when available, and transparently
24
+ falls back to the standard ``venv``/``pip`` tooling otherwise. This makes
25
+ the package usable on systems where ``uv`` has not yet been installed while
26
+ still taking advantage of ``uv`` when it is present.
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ workspace: Path | str | None = None,
32
+ venv_name: str = ".venv",
33
+ python: str | None = None,
34
+ keep: bool = False,
35
+ backend: str | None = None,
36
+ ) -> None:
37
+ """Initialize a sandbox manager.
38
+
39
+ Args:
40
+ workspace: Directory that will host the virtual environment. If ``None``,
41
+ a temporary directory is created.
42
+ venv_name: Name of the virtual environment directory inside ``workspace``.
43
+ python: Python version or interpreter requested for the backend.
44
+ keep: If ``False`` (default), the workspace is removed when the manager
45
+ is garbage collected or ``cleanup`` is called.
46
+ backend: Environment backend to use. ``"uv"`` or ``"venv"``. When ``None``,
47
+ ``uv`` is auto-detected and preferred.
48
+ """
49
+ self._workspace = (
50
+ Path(workspace)
51
+ if workspace
52
+ else Path(tempfile.mkdtemp(prefix="codeshield_"))
53
+ )
54
+ self._venv_name = venv_name
55
+ self._python = python or sys.executable
56
+ self._keep = keep
57
+ self._backend = self._resolve_backend(backend)
58
+ self._uv_path: Path | None = None
59
+ self._venv_path = self._workspace / self._venv_name
60
+
61
+ @property
62
+ def workspace(self) -> Path:
63
+ """Return the root sandbox workspace directory."""
64
+ return self._workspace
65
+
66
+ @property
67
+ def venv_path(self) -> Path:
68
+ """Return the virtual environment directory."""
69
+ return self._venv_path
70
+
71
+ @property
72
+ def python_executable(self) -> Path:
73
+ """Return the interpreter path inside the virtual environment."""
74
+ if self._is_windows():
75
+ return self._venv_path / "Scripts" / "python.exe"
76
+ return self._venv_path / "bin" / "python"
77
+
78
+ def uv_executable(self) -> Path:
79
+ """Resolve and cache the ``uv`` executable path.
80
+
81
+ Raises:
82
+ SandboxError: when ``uv`` is not available on ``PATH``.
83
+ """
84
+ if self._uv_path is None:
85
+ uv = shutil.which("uv")
86
+ if uv is None:
87
+ raise SandboxError(
88
+ "The `uv` executable was not found on PATH. "
89
+ "Install uv from https://github.com/astral-sh/uv"
90
+ )
91
+ self._uv_path = Path(uv)
92
+ return self._uv_path
93
+
94
+ def create(self) -> Path:
95
+ """Create the virtual environment.
96
+
97
+ Returns:
98
+ The path to the virtual environment.
99
+
100
+ Raises:
101
+ SandboxError: if the backend fails to create the environment.
102
+ """
103
+ self._workspace.mkdir(parents=True, exist_ok=True)
104
+
105
+ if self._backend == "uv":
106
+ self._create_with_uv()
107
+ else:
108
+ self._create_with_venv()
109
+
110
+ if not self.python_executable.exists():
111
+ raise SandboxError(
112
+ f"Virtual environment was created but interpreter not found at "
113
+ f"{self.python_executable}"
114
+ )
115
+
116
+ logger.info("Virtual environment ready at %s", self._venv_path)
117
+ return self._venv_path
118
+
119
+ def _create_with_uv(self) -> None:
120
+ """Create a virtual environment using ``uv venv``."""
121
+ cmd: list[str] = [str(self.uv_executable()), "venv"]
122
+ if self._python:
123
+ cmd.extend(["--python", self._python])
124
+ cmd.append(str(self._venv_name))
125
+
126
+ logger.info("Creating uv venv at %s", self._venv_path)
127
+ result = self._run_command(cmd, cwd=self._workspace)
128
+ if result.returncode != 0:
129
+ raise SandboxError(
130
+ f"`uv venv` failed (exit {result.returncode}): "
131
+ f"{result.stderr or result.stdout}"
132
+ )
133
+
134
+ def _create_with_venv(self) -> None:
135
+ """Create a virtual environment using the standard ``venv`` module."""
136
+ cmd = [self._python, "-m", "venv", str(self._venv_name)]
137
+ logger.info("Creating venv with %s at %s", self._python, self._venv_path)
138
+ result = self._run_command(cmd, cwd=self._workspace)
139
+ if result.returncode != 0:
140
+ raise SandboxError(
141
+ f"`python -m venv` failed (exit {result.returncode}): "
142
+ f"{result.stderr or result.stdout}"
143
+ )
144
+
145
+ def install_requirements(self, requirements: Sequence[str] | Path | str) -> None:
146
+ """Install packages from a list or a ``requirements.txt`` path.
147
+
148
+ Args:
149
+ requirements: Either a sequence of package specifiers or a path
150
+ to a requirements file.
151
+
152
+ Raises:
153
+ SandboxError: if the installation fails.
154
+ """
155
+ if isinstance(requirements, (str, Path)):
156
+ requirements_path = Path(requirements)
157
+ if not requirements_path.exists():
158
+ raise SandboxError(f"Requirements file not found: {requirements_path}")
159
+
160
+ if self._backend == "uv":
161
+ cmd = [
162
+ str(self.uv_executable()),
163
+ "pip",
164
+ "install",
165
+ "-r",
166
+ str(requirements_path),
167
+ ]
168
+ else:
169
+ cmd = [
170
+ str(self.python_executable),
171
+ "-m",
172
+ "pip",
173
+ "install",
174
+ "-r",
175
+ str(requirements_path),
176
+ ]
177
+ else:
178
+ if not requirements:
179
+ return
180
+ if self._backend == "uv":
181
+ cmd = [str(self.uv_executable()), "pip", "install", *requirements]
182
+ else:
183
+ cmd = [str(self.python_executable), "-m", "pip", "install", *requirements]
184
+
185
+ logger.info("Installing requirements in sandbox: %s", cmd)
186
+ result = self._run_command(cmd, cwd=self._workspace)
187
+ if result.returncode != 0:
188
+ raise SandboxError(
189
+ f"Requirement installation failed (exit {result.returncode}): "
190
+ f"{result.stderr or result.stdout}"
191
+ )
192
+
193
+ def write_requirements_file(
194
+ self, packages: Sequence[str], file_name: str = "requirements.txt"
195
+ ) -> Path:
196
+ """Write package specifiers to ``workspace/requirements.txt``.
197
+
198
+ Returns:
199
+ Path of the written file.
200
+ """
201
+ path = self._workspace / file_name
202
+ path.write_text("\n".join(packages) + "\n", encoding="utf-8")
203
+ return path
204
+
205
+ def cleanup(self) -> None:
206
+ """Remove the workspace unless ``keep`` was set to ``True``."""
207
+ if self._keep or not self._workspace.exists():
208
+ return
209
+ logger.info("Cleaning up sandbox workspace: %s", self._workspace)
210
+ try:
211
+ shutil.rmtree(self._workspace, ignore_errors=True)
212
+ except OSError as exc:
213
+ logger.warning("Could not remove workspace %s: %s", self._workspace, exc)
214
+
215
+ def __enter__(self) -> SandboxManager:
216
+ self.create()
217
+ return self
218
+
219
+ def __exit__(self, *exc: object) -> None:
220
+ self.cleanup()
221
+
222
+ def _resolve_backend(self, backend: str | None) -> str:
223
+ """Resolve the environment backend, preferring ``uv`` when available."""
224
+ if backend in {"uv", "venv"}:
225
+ return backend
226
+ if backend is not None:
227
+ raise SandboxError(f"Unknown backend '{backend}'; choose 'uv' or 'venv'")
228
+ if shutil.which("uv") is not None:
229
+ return "uv"
230
+ logger.warning(
231
+ "uv was not found on PATH; falling back to the standard venv/pip backend. "
232
+ "Install uv from https://github.com/astral-sh/uv for faster sandboxes."
233
+ )
234
+ return "venv"
235
+
236
+ def _run_command(
237
+ self,
238
+ cmd: list[str],
239
+ cwd: Path,
240
+ ) -> subprocess.CompletedProcess[str]:
241
+ """Run a command and return a ``CompletedProcess`` with text output."""
242
+ try:
243
+ return subprocess.run(
244
+ cmd,
245
+ cwd=cwd,
246
+ capture_output=True,
247
+ text=True,
248
+ check=False,
249
+ )
250
+ except OSError as exc:
251
+ raise SandboxError(f"Failed to spawn command {cmd[0]!r}: {exc}") from exc
252
+
253
+ @staticmethod
254
+ def _is_windows() -> bool:
255
+ """Return ``True`` when running on Windows."""
256
+ return sys.platform.startswith("win")