|
7 | 7 |
|
8 | 8 | from __future__ import annotations |
9 | 9 |
|
| 10 | +import asyncio |
| 11 | +import contextlib |
| 12 | +import logging |
| 13 | +import sys |
10 | 14 | import time |
11 | 15 | from collections.abc import Sequence |
| 16 | +from pathlib import Path |
12 | 17 |
|
13 | 18 | from .filters import redact_text |
14 | 19 | from .governance import SandboxRequest |
15 | 20 | from .schemas import ParsedDiff |
16 | 21 | from .schemas import SandboxPolicy |
17 | 22 | from .schemas import SandboxRun |
18 | 23 |
|
| 24 | +_SCRIPT_FILES = { |
| 25 | + "diff_summary": "diff_summary.py", |
| 26 | + "static_rules": "static_rules.py", |
| 27 | +} |
| 28 | + |
19 | 29 |
|
20 | 30 | class FakeSandboxRunner: |
21 | 31 | """Deterministic sandbox runner that never executes host commands.""" |
22 | 32 |
|
23 | 33 | def __init__(self, policy: SandboxPolicy) -> None: |
24 | 34 | self._policy = policy |
25 | 35 |
|
26 | | - def run_requests(self, requests: Sequence[SandboxRequest], parsed_diff: ParsedDiff) -> list[SandboxRun]: |
| 36 | + def run_requests( |
| 37 | + self, requests: Sequence[SandboxRequest], parsed_diff: ParsedDiff, diff_text: str = "" |
| 38 | + ) -> list[SandboxRun]: |
27 | 39 | """Run all allowed requests deterministically.""" |
28 | 40 | return [self.run_request(request, parsed_diff) for request in requests] |
29 | 41 |
|
@@ -72,6 +84,149 @@ def run_request(self, request: SandboxRequest, parsed_diff: ParsedDiff) -> Sandb |
72 | 84 | ) |
73 | 85 |
|
74 | 86 |
|
| 87 | +class ContainerSandboxRunner: |
| 88 | + """Sandbox runner backed by the project's existing Docker ContainerClient.""" |
| 89 | + |
| 90 | + def __init__(self, policy: SandboxPolicy, *, scripts_dir: Path | None = None, image: str = "python:3-slim") -> None: |
| 91 | + self._policy = policy |
| 92 | + self._scripts_dir = scripts_dir or Path(__file__).resolve().parents[1] / "skills" / "code-review" / "scripts" |
| 93 | + self._image = image |
| 94 | + |
| 95 | + def run_requests( |
| 96 | + self, requests: Sequence[SandboxRequest], parsed_diff: ParsedDiff, diff_text: str = "" |
| 97 | + ) -> list[SandboxRun]: |
| 98 | + """Run allowed requests in Docker containers.""" |
| 99 | + return [self.run_request(request, diff_text) for request in requests] |
| 100 | + |
| 101 | + def run_request(self, request: SandboxRequest, diff_text: str) -> SandboxRun: |
| 102 | + """Run one allowlisted script in a Docker container.""" |
| 103 | + started = time.monotonic() |
| 104 | + script_file = _SCRIPT_FILES.get(request.script_name) |
| 105 | + if script_file is None: |
| 106 | + return _build_run( |
| 107 | + request, |
| 108 | + runtime="container", |
| 109 | + started=started, |
| 110 | + exit_code=1, |
| 111 | + stderr=f"no container script is mapped for {request.script_name}", |
| 112 | + error_type="SandboxScriptUnavailable", |
| 113 | + policy=self._policy, |
| 114 | + ) |
| 115 | + |
| 116 | + try: |
| 117 | + return asyncio.run(self._run_request_async(request, script_file, diff_text, started)) |
| 118 | + except Exception as exc: # pylint: disable=broad-except |
| 119 | + return _build_run( |
| 120 | + request, |
| 121 | + runtime="container", |
| 122 | + started=started, |
| 123 | + exit_code=-1, |
| 124 | + stderr=str(exc), |
| 125 | + error_type=exc.__class__.__name__, |
| 126 | + policy=self._policy, |
| 127 | + ) |
| 128 | + |
| 129 | + async def _run_request_async( |
| 130 | + self, request: SandboxRequest, script_file: str, diff_text: str, started: float |
| 131 | + ) -> SandboxRun: |
| 132 | + CommandArgs, ContainerClient, ContainerConfig = _load_container_runtime() |
| 133 | + _redirect_trpc_agent_logs_to_stderr() |
| 134 | + with contextlib.redirect_stdout(sys.stderr): |
| 135 | + client = ContainerClient( |
| 136 | + ContainerConfig( |
| 137 | + image=self._image, |
| 138 | + host_config={ |
| 139 | + "Binds": [f"{self._scripts_dir}:/workspace/scripts:ro"], |
| 140 | + "network_mode": "none", |
| 141 | + "working_dir": "/workspace", |
| 142 | + }, |
| 143 | + ) |
| 144 | + ) |
| 145 | + try: |
| 146 | + with contextlib.redirect_stdout(sys.stderr): |
| 147 | + result = await client.exec_run( |
| 148 | + ["python3", f"/workspace/scripts/{script_file}"], |
| 149 | + CommandArgs(timeout=self._policy.timeout_seconds, stdin=diff_text, environment={}), |
| 150 | + ) |
| 151 | + finally: |
| 152 | + cleanup = getattr(client, "_cleanup_container", None) |
| 153 | + if callable(cleanup): |
| 154 | + cleanup() |
| 155 | + |
| 156 | + error_type = None |
| 157 | + if result.is_timeout: |
| 158 | + error_type = "SandboxTimeout" |
| 159 | + elif result.exit_code: |
| 160 | + error_type = "SandboxCommandFailed" |
| 161 | + return _build_run( |
| 162 | + request, |
| 163 | + runtime="container", |
| 164 | + started=started, |
| 165 | + exit_code=result.exit_code, |
| 166 | + stdout=result.stdout, |
| 167 | + stderr=result.stderr, |
| 168 | + timed_out=result.is_timeout, |
| 169 | + error_type=error_type, |
| 170 | + policy=self._policy, |
| 171 | + ) |
| 172 | + |
| 173 | + |
| 174 | + |
| 175 | +def _redirect_trpc_agent_logs_to_stderr() -> None: |
| 176 | + logging.getLogger("trpc_agent_sdk").disabled = True |
| 177 | + |
| 178 | + |
| 179 | +def _load_container_runtime(): |
| 180 | + try: |
| 181 | + from trpc_agent_sdk.code_executors.container import CommandArgs |
| 182 | + from trpc_agent_sdk.code_executors.container import ContainerClient |
| 183 | + from trpc_agent_sdk.code_executors.container import ContainerConfig |
| 184 | + except ModuleNotFoundError: |
| 185 | + repo_root = Path(__file__).resolve().parents[3] |
| 186 | + sys.path.insert(0, str(repo_root)) |
| 187 | + from trpc_agent_sdk.code_executors.container import CommandArgs |
| 188 | + from trpc_agent_sdk.code_executors.container import ContainerClient |
| 189 | + from trpc_agent_sdk.code_executors.container import ContainerConfig |
| 190 | + return CommandArgs, ContainerClient, ContainerConfig |
| 191 | + |
| 192 | + |
| 193 | +def create_sandbox_runner(policy: SandboxPolicy, *, container_image: str = "python:3-slim") -> FakeSandboxRunner | ContainerSandboxRunner: |
| 194 | + """Create a sandbox runner for the configured runtime.""" |
| 195 | + if policy.runtime == "container": |
| 196 | + return ContainerSandboxRunner(policy, image=container_image) |
| 197 | + return FakeSandboxRunner(policy) |
| 198 | + |
| 199 | + |
| 200 | +def _build_run( |
| 201 | + request: SandboxRequest, |
| 202 | + *, |
| 203 | + runtime: str, |
| 204 | + started: float, |
| 205 | + exit_code: int | None, |
| 206 | + stdout: str = "", |
| 207 | + stderr: str = "", |
| 208 | + timed_out: bool = False, |
| 209 | + error_type: str | None = None, |
| 210 | + policy: SandboxPolicy, |
| 211 | +) -> SandboxRun: |
| 212 | + stdout_excerpt, stdout_truncated = _cap_output(redact_text(stdout), policy.max_output_bytes) |
| 213 | + stderr_excerpt, stderr_truncated = _cap_output(redact_text(stderr), policy.max_output_bytes) |
| 214 | + duration_ms = max(0, int((time.monotonic() - started) * 1000)) |
| 215 | + return SandboxRun( |
| 216 | + id=f"sandbox-{request.script_name}", |
| 217 | + script_name=request.script_name, |
| 218 | + runtime=runtime, |
| 219 | + decision="allow", |
| 220 | + exit_code=exit_code, |
| 221 | + timed_out=timed_out, |
| 222 | + duration_ms=duration_ms, |
| 223 | + stdout_excerpt=stdout_excerpt, |
| 224 | + stderr_excerpt=stderr_excerpt, |
| 225 | + output_truncated=stdout_truncated or stderr_truncated, |
| 226 | + error_type=error_type, |
| 227 | + ) |
| 228 | + |
| 229 | + |
75 | 230 | def _cap_output(text: str, max_bytes: int) -> tuple[str, bool]: |
76 | 231 | raw = text.encode("utf-8") |
77 | 232 | if len(raw) <= max_bytes: |
|
0 commit comments