tiphys.sandbox
Tiphys sandbox system.
Provides secure execution environments for untrusted contexts.
1""" 2Tiphys sandbox system. 3 4Provides secure execution environments for untrusted contexts. 5""" 6 7from tiphys.sandbox.docker import ( 8 DockerSandbox, 9 SandboxConfig, 10 SandboxResult, 11 create_sandbox, 12 get_sandbox_manager, 13) 14from tiphys.sandbox.wrapper import ( 15 SandboxedToolRegistry, 16 wrap_tool_for_sandbox, 17) 18 19__all__ = [ 20 # Docker sandbox 21 "DockerSandbox", 22 "SandboxConfig", 23 "SandboxResult", 24 "create_sandbox", 25 "get_sandbox_manager", 26 # Wrapper 27 "SandboxedToolRegistry", 28 "wrap_tool_for_sandbox", 29]
114@dataclass 115class DockerSandbox: 116 """ 117 Docker-based sandbox for secure execution. 118 119 Provides isolated environments with resource limits for running 120 untrusted commands safely. 121 """ 122 123 config: SandboxConfig 124 container_id: str | None = None 125 status: SandboxStatus = SandboxStatus.CREATED 126 _docker_available: bool | None = None 127 128 async def _check_docker(self) -> bool: 129 """Check if Docker is available.""" 130 if self._docker_available is not None: 131 return self._docker_available 132 133 try: 134 proc = await asyncio.create_subprocess_exec( 135 "docker", 136 "version", 137 stdout=asyncio.subprocess.DEVNULL, 138 stderr=asyncio.subprocess.DEVNULL, 139 ) 140 await proc.wait() 141 self._docker_available = proc.returncode == 0 142 except FileNotFoundError: 143 self._docker_available = False 144 145 return self._docker_available 146 147 async def start(self) -> bool: 148 """ 149 Start the sandbox container. 150 151 Returns: 152 True if started successfully, False otherwise. 153 """ 154 if not await self._check_docker(): 155 logger.warning("Docker not available, sandbox disabled") 156 self.status = SandboxStatus.ERROR 157 return False 158 159 try: 160 # Build docker run command 161 cmd = self._build_docker_run_cmd() 162 163 proc = await asyncio.create_subprocess_exec( 164 *cmd, 165 stdout=asyncio.subprocess.PIPE, 166 stderr=asyncio.subprocess.PIPE, 167 ) 168 stdout, stderr = await proc.communicate() 169 170 if proc.returncode == 0: 171 self.container_id = stdout.decode().strip() 172 self.status = SandboxStatus.RUNNING 173 logger.info("Sandbox started", container_id=self.container_id[:12]) 174 return True 175 else: 176 logger.error( 177 "Failed to start sandbox", 178 stderr=stderr.decode(), 179 ) 180 self.status = SandboxStatus.ERROR 181 return False 182 183 except Exception as e: 184 logger.error("Sandbox start error", error=str(e)) 185 self.status = SandboxStatus.ERROR 186 return False 187 188 async def stop(self) -> bool: 189 """ 190 Stop and remove the sandbox container. 191 192 Returns: 193 True if stopped successfully. 194 """ 195 if not self.container_id: 196 return True 197 198 try: 199 # Stop container 200 proc = await asyncio.create_subprocess_exec( 201 "docker", 202 "stop", 203 "-t", 204 "5", 205 self.container_id, 206 stdout=asyncio.subprocess.DEVNULL, 207 stderr=asyncio.subprocess.DEVNULL, 208 ) 209 await proc.wait() 210 211 # Remove container 212 proc = await asyncio.create_subprocess_exec( 213 "docker", 214 "rm", 215 "-f", 216 self.container_id, 217 stdout=asyncio.subprocess.DEVNULL, 218 stderr=asyncio.subprocess.DEVNULL, 219 ) 220 await proc.wait() 221 222 logger.info("Sandbox stopped", container_id=self.container_id[:12]) 223 self.container_id = None 224 self.status = SandboxStatus.STOPPED 225 return True 226 227 except Exception as e: 228 logger.error("Sandbox stop error", error=str(e)) 229 return False 230 231 async def execute( 232 self, 233 command: str, 234 timeout: int | None = None, 235 workdir: str | None = None, 236 ) -> SandboxResult: 237 """ 238 Execute a command in the sandbox. 239 240 Args: 241 command: Shell command to execute. 242 timeout: Optional timeout override. 243 workdir: Working directory inside container. 244 245 Returns: 246 SandboxResult with output and status. 247 """ 248 import time 249 250 start_time = time.monotonic() 251 252 if self.status != SandboxStatus.RUNNING or not self.container_id: 253 return SandboxResult( 254 exit_code=-1, 255 stdout="", 256 stderr="", 257 error="Sandbox not running", 258 ) 259 260 effective_timeout = timeout or self.config.timeout_seconds 261 262 try: 263 # Build exec command 264 exec_cmd = ["docker", "exec"] 265 266 if workdir: 267 exec_cmd.extend(["-w", workdir]) 268 269 exec_cmd.extend( 270 [ 271 self.container_id, 272 "/bin/sh", 273 "-c", 274 command, 275 ] 276 ) 277 278 proc = await asyncio.create_subprocess_exec( 279 *exec_cmd, 280 stdout=asyncio.subprocess.PIPE, 281 stderr=asyncio.subprocess.PIPE, 282 ) 283 284 try: 285 stdout, stderr = await asyncio.wait_for( 286 proc.communicate(), 287 timeout=effective_timeout, 288 ) 289 duration_ms = int((time.monotonic() - start_time) * 1000) 290 291 return SandboxResult( 292 exit_code=proc.returncode or 0, 293 stdout=stdout.decode(errors="replace"), 294 stderr=stderr.decode(errors="replace"), 295 duration_ms=duration_ms, 296 ) 297 298 except TimeoutError: 299 proc.kill() 300 await proc.wait() 301 duration_ms = int((time.monotonic() - start_time) * 1000) 302 303 return SandboxResult( 304 exit_code=-1, 305 stdout="", 306 stderr="", 307 timed_out=True, 308 error=f"Command timed out after {effective_timeout}s", 309 duration_ms=duration_ms, 310 ) 311 312 except Exception as e: 313 duration_ms = int((time.monotonic() - start_time) * 1000) 314 return SandboxResult( 315 exit_code=-1, 316 stdout="", 317 stderr="", 318 error=str(e), 319 duration_ms=duration_ms, 320 ) 321 322 async def write_file(self, path: str, content: str) -> bool: 323 """ 324 Write a file inside the sandbox. 325 326 Args: 327 path: Path inside the container. 328 content: File content. 329 330 Returns: 331 True if successful. 332 """ 333 if self.status != SandboxStatus.RUNNING: 334 return False 335 336 try: 337 # Use docker exec with echo/cat 338 # Escape content for shell 339 content.replace("'", "'\"'\"'") 340 cmd = f"cat > {path} << 'TIPHYS_EOF'\n{content}\nTIPHYS_EOF" 341 342 result = await self.execute(cmd) 343 return result.exit_code == 0 344 345 except Exception as e: 346 logger.error("Write file error", path=path, error=str(e)) 347 return False 348 349 async def read_file(self, path: str) -> str | None: 350 """ 351 Read a file from the sandbox. 352 353 Args: 354 path: Path inside the container. 355 356 Returns: 357 File content, or None if failed. 358 """ 359 if self.status != SandboxStatus.RUNNING: 360 return None 361 362 try: 363 result = await self.execute(f"cat {path}") 364 if result.exit_code == 0: 365 return result.stdout 366 return None 367 368 except Exception as e: 369 logger.error("Read file error", path=path, error=str(e)) 370 return None 371 372 def _build_docker_run_cmd(self) -> list[str]: 373 """Build the docker run command.""" 374 import uuid 375 376 container_name = f"{self.config.name_prefix}-{uuid.uuid4().hex[:8]}" 377 378 cmd = [ 379 "docker", 380 "run", 381 "-d", # Detached 382 "--name", 383 container_name, 384 "--memory", 385 self.config.memory_limit, 386 f"--cpus={self.config.cpu_limit}", 387 "--user", 388 self.config.user, 389 ] 390 391 # Network settings 392 if not self.config.network_enabled: 393 cmd.extend(["--network", self.config.network_mode]) 394 395 # Security settings 396 for cap in self.config.drop_capabilities: 397 cmd.extend(["--cap-drop", cap]) 398 399 for opt in self.config.security_opt: 400 cmd.extend(["--security-opt", opt]) 401 402 # Volume mounts 403 if self.config.workspace_path: 404 mount_opts = "ro" if self.config.read_only_workspace else "rw" 405 cmd.extend( 406 [ 407 "-v", 408 f"{self.config.workspace_path}:{self.config.workspace_mount}:{mount_opts}", 409 ] 410 ) 411 412 # Image and keep-alive command 413 cmd.extend( 414 [ 415 self.config.image, 416 "tail", 417 "-f", 418 "/dev/null", # Keep container running 419 ] 420 ) 421 422 return cmd
Docker-based sandbox for secure execution.
Provides isolated environments with resource limits for running untrusted commands safely.
147 async def start(self) -> bool: 148 """ 149 Start the sandbox container. 150 151 Returns: 152 True if started successfully, False otherwise. 153 """ 154 if not await self._check_docker(): 155 logger.warning("Docker not available, sandbox disabled") 156 self.status = SandboxStatus.ERROR 157 return False 158 159 try: 160 # Build docker run command 161 cmd = self._build_docker_run_cmd() 162 163 proc = await asyncio.create_subprocess_exec( 164 *cmd, 165 stdout=asyncio.subprocess.PIPE, 166 stderr=asyncio.subprocess.PIPE, 167 ) 168 stdout, stderr = await proc.communicate() 169 170 if proc.returncode == 0: 171 self.container_id = stdout.decode().strip() 172 self.status = SandboxStatus.RUNNING 173 logger.info("Sandbox started", container_id=self.container_id[:12]) 174 return True 175 else: 176 logger.error( 177 "Failed to start sandbox", 178 stderr=stderr.decode(), 179 ) 180 self.status = SandboxStatus.ERROR 181 return False 182 183 except Exception as e: 184 logger.error("Sandbox start error", error=str(e)) 185 self.status = SandboxStatus.ERROR 186 return False
Start the sandbox container.
Returns: True if started successfully, False otherwise.
188 async def stop(self) -> bool: 189 """ 190 Stop and remove the sandbox container. 191 192 Returns: 193 True if stopped successfully. 194 """ 195 if not self.container_id: 196 return True 197 198 try: 199 # Stop container 200 proc = await asyncio.create_subprocess_exec( 201 "docker", 202 "stop", 203 "-t", 204 "5", 205 self.container_id, 206 stdout=asyncio.subprocess.DEVNULL, 207 stderr=asyncio.subprocess.DEVNULL, 208 ) 209 await proc.wait() 210 211 # Remove container 212 proc = await asyncio.create_subprocess_exec( 213 "docker", 214 "rm", 215 "-f", 216 self.container_id, 217 stdout=asyncio.subprocess.DEVNULL, 218 stderr=asyncio.subprocess.DEVNULL, 219 ) 220 await proc.wait() 221 222 logger.info("Sandbox stopped", container_id=self.container_id[:12]) 223 self.container_id = None 224 self.status = SandboxStatus.STOPPED 225 return True 226 227 except Exception as e: 228 logger.error("Sandbox stop error", error=str(e)) 229 return False
Stop and remove the sandbox container.
Returns: True if stopped successfully.
231 async def execute( 232 self, 233 command: str, 234 timeout: int | None = None, 235 workdir: str | None = None, 236 ) -> SandboxResult: 237 """ 238 Execute a command in the sandbox. 239 240 Args: 241 command: Shell command to execute. 242 timeout: Optional timeout override. 243 workdir: Working directory inside container. 244 245 Returns: 246 SandboxResult with output and status. 247 """ 248 import time 249 250 start_time = time.monotonic() 251 252 if self.status != SandboxStatus.RUNNING or not self.container_id: 253 return SandboxResult( 254 exit_code=-1, 255 stdout="", 256 stderr="", 257 error="Sandbox not running", 258 ) 259 260 effective_timeout = timeout or self.config.timeout_seconds 261 262 try: 263 # Build exec command 264 exec_cmd = ["docker", "exec"] 265 266 if workdir: 267 exec_cmd.extend(["-w", workdir]) 268 269 exec_cmd.extend( 270 [ 271 self.container_id, 272 "/bin/sh", 273 "-c", 274 command, 275 ] 276 ) 277 278 proc = await asyncio.create_subprocess_exec( 279 *exec_cmd, 280 stdout=asyncio.subprocess.PIPE, 281 stderr=asyncio.subprocess.PIPE, 282 ) 283 284 try: 285 stdout, stderr = await asyncio.wait_for( 286 proc.communicate(), 287 timeout=effective_timeout, 288 ) 289 duration_ms = int((time.monotonic() - start_time) * 1000) 290 291 return SandboxResult( 292 exit_code=proc.returncode or 0, 293 stdout=stdout.decode(errors="replace"), 294 stderr=stderr.decode(errors="replace"), 295 duration_ms=duration_ms, 296 ) 297 298 except TimeoutError: 299 proc.kill() 300 await proc.wait() 301 duration_ms = int((time.monotonic() - start_time) * 1000) 302 303 return SandboxResult( 304 exit_code=-1, 305 stdout="", 306 stderr="", 307 timed_out=True, 308 error=f"Command timed out after {effective_timeout}s", 309 duration_ms=duration_ms, 310 ) 311 312 except Exception as e: 313 duration_ms = int((time.monotonic() - start_time) * 1000) 314 return SandboxResult( 315 exit_code=-1, 316 stdout="", 317 stderr="", 318 error=str(e), 319 duration_ms=duration_ms, 320 )
Execute a command in the sandbox.
Args: command: Shell command to execute. timeout: Optional timeout override. workdir: Working directory inside container.
Returns: SandboxResult with output and status.
322 async def write_file(self, path: str, content: str) -> bool: 323 """ 324 Write a file inside the sandbox. 325 326 Args: 327 path: Path inside the container. 328 content: File content. 329 330 Returns: 331 True if successful. 332 """ 333 if self.status != SandboxStatus.RUNNING: 334 return False 335 336 try: 337 # Use docker exec with echo/cat 338 # Escape content for shell 339 content.replace("'", "'\"'\"'") 340 cmd = f"cat > {path} << 'TIPHYS_EOF'\n{content}\nTIPHYS_EOF" 341 342 result = await self.execute(cmd) 343 return result.exit_code == 0 344 345 except Exception as e: 346 logger.error("Write file error", path=path, error=str(e)) 347 return False
Write a file inside the sandbox.
Args: path: Path inside the container. content: File content.
Returns: True if successful.
349 async def read_file(self, path: str) -> str | None: 350 """ 351 Read a file from the sandbox. 352 353 Args: 354 path: Path inside the container. 355 356 Returns: 357 File content, or None if failed. 358 """ 359 if self.status != SandboxStatus.RUNNING: 360 return None 361 362 try: 363 result = await self.execute(f"cat {path}") 364 if result.exit_code == 0: 365 return result.stdout 366 return None 367 368 except Exception as e: 369 logger.error("Read file error", path=path, error=str(e)) 370 return None
Read a file from the sandbox.
Args: path: Path inside the container.
Returns: File content, or None if failed.
28class SandboxConfig(BaseModel): 29 """Configuration for a sandbox environment.""" 30 31 # Container settings 32 image: str = Field( 33 default="python:3.12-slim", 34 description="Docker image to use", 35 ) 36 name_prefix: str = Field( 37 default="chela-sandbox", 38 description="Prefix for container names", 39 ) 40 41 # Resource limits 42 memory_limit: str = Field( 43 default="512m", 44 description="Memory limit (e.g., '512m', '1g')", 45 ) 46 cpu_limit: float = Field( 47 default=1.0, 48 ge=0.1, 49 le=16.0, 50 description="CPU limit (number of cores)", 51 ) 52 timeout_seconds: int = Field( 53 default=300, 54 ge=10, 55 le=3600, 56 description="Maximum execution time in seconds", 57 ) 58 59 # Network settings 60 network_enabled: bool = Field( 61 default=False, 62 description="Whether to allow network access", 63 ) 64 network_mode: str = Field( 65 default="none", 66 description="Docker network mode", 67 ) 68 69 # Volume settings 70 workspace_path: Path | None = Field( 71 default=None, 72 description="Host path to mount as workspace", 73 ) 74 workspace_mount: str = Field( 75 default="/workspace", 76 description="Container path for workspace mount", 77 ) 78 read_only_workspace: bool = Field( 79 default=False, 80 description="Mount workspace as read-only", 81 ) 82 83 # Security settings 84 privileged: bool = Field( 85 default=False, 86 description="Run container in privileged mode (NOT recommended)", 87 ) 88 user: str = Field( 89 default="nobody", 90 description="User to run commands as", 91 ) 92 drop_capabilities: list[str] = Field( 93 default_factory=lambda: ["ALL"], 94 description="Capabilities to drop", 95 ) 96 security_opt: list[str] = Field( 97 default_factory=lambda: ["no-new-privileges:true"], 98 description="Security options", 99 )
Configuration for a sandbox environment.
102@dataclass 103class SandboxResult: 104 """Result from a sandbox command execution.""" 105 106 exit_code: int 107 stdout: str 108 stderr: str 109 timed_out: bool = False 110 error: str | None = None 111 duration_ms: int = 0
Result from a sandbox command execution.
484async def create_sandbox( 485 name: str | None = None, 486 config: SandboxConfig | None = None, 487) -> DockerSandbox: 488 """ 489 Create a new sandbox instance. 490 491 Args: 492 name: Optional name (auto-generated if not provided). 493 config: Optional configuration. 494 495 Returns: 496 The created sandbox. 497 """ 498 import uuid 499 500 sandbox_name = name or f"sandbox-{uuid.uuid4().hex[:8]}" 501 return await get_sandbox_manager().create(sandbox_name, config)
Create a new sandbox instance.
Args: name: Optional name (auto-generated if not provided). config: Optional configuration.
Returns: The created sandbox.
476def get_sandbox_manager() -> SandboxManager: 477 """Get the global sandbox manager.""" 478 global _sandbox_manager 479 if _sandbox_manager is None: 480 _sandbox_manager = SandboxManager() 481 return _sandbox_manager
Get the global sandbox manager.
20class SandboxedToolRegistry(ToolRegistry): 21 """ 22 Tool registry that wraps tools to run in a sandbox. 23 24 Intercepts tool execution and redirects it to a sandbox container. 25 """ 26 27 def __init__( 28 self, 29 sandbox: DockerSandbox | None = None, 30 sandbox_config: SandboxConfig | None = None, 31 sandboxed_tools: set[str] | None = None, 32 ) -> None: 33 """ 34 Initialize sandboxed registry. 35 36 Args: 37 sandbox: Existing sandbox to use. 38 sandbox_config: Config for creating a new sandbox. 39 sandboxed_tools: Set of tool names to sandbox. None = all tools. 40 """ 41 super().__init__() 42 self._sandbox = sandbox 43 self._sandbox_config = sandbox_config or SandboxConfig() 44 self._sandboxed_tools = sandboxed_tools 45 self._original_handlers: dict[str, Callable[..., Awaitable[str]]] = {} 46 47 async def ensure_sandbox(self) -> DockerSandbox | None: 48 """Ensure sandbox is running.""" 49 if self._sandbox is None: 50 self._sandbox = await create_sandbox(config=self._sandbox_config) 51 return self._sandbox 52 53 def register(self, tool: ToolDefinition) -> None: 54 """Register a tool, wrapping it for sandbox execution if needed.""" 55 # Store original handler 56 self._original_handlers[tool.name] = tool.handler 57 58 # Check if this tool should be sandboxed 59 should_sandbox = self._sandboxed_tools is None or tool.name in self._sandboxed_tools 60 61 if should_sandbox and self._is_sandboxable(tool.name): 62 # Create sandboxed wrapper 63 wrapped_handler = self._create_sandbox_wrapper(tool) 64 tool = ToolDefinition( 65 name=tool.name, 66 description=tool.description + " (sandboxed)", 67 args_schema=tool.args_schema, 68 handler=wrapped_handler, 69 requires_permission=tool.requires_permission, 70 optional=tool.optional, 71 ) 72 73 super().register(tool) 74 75 def _is_sandboxable(self, tool_name: str) -> bool: 76 """Check if a tool can be sandboxed.""" 77 # Tools that interact with filesystem or run commands 78 sandboxable = { 79 "run_command", 80 "write_file", 81 "read_file", 82 "list_directory", 83 } 84 return tool_name in sandboxable 85 86 def _create_sandbox_wrapper( 87 self, 88 tool: ToolDefinition, 89 ) -> Callable[..., Awaitable[str]]: 90 """Create a wrapper that executes tool in sandbox.""" 91 original_handler = tool.handler 92 tool_name = tool.name 93 94 @wraps(original_handler) 95 async def wrapper(**kwargs: Any) -> str: 96 sandbox = await self.ensure_sandbox() 97 98 if sandbox is None or sandbox.status.value != "running": 99 # Fall back to original handler if sandbox unavailable 100 logger.warning( 101 "Sandbox unavailable, using direct execution", 102 tool=tool_name, 103 ) 104 return await original_handler(**kwargs) 105 106 # Route to appropriate sandbox method 107 return await self._execute_in_sandbox(sandbox, tool_name, kwargs) 108 109 return wrapper 110 111 async def _execute_in_sandbox( 112 self, 113 sandbox: DockerSandbox, 114 tool_name: str, 115 kwargs: dict[str, Any], 116 ) -> str: 117 """Execute a tool in the sandbox.""" 118 match tool_name: 119 case "run_command": 120 return await self._sandbox_run_command(sandbox, kwargs) 121 case "write_file": 122 return await self._sandbox_write_file(sandbox, kwargs) 123 case "read_file": 124 return await self._sandbox_read_file(sandbox, kwargs) 125 case "list_directory": 126 return await self._sandbox_list_directory(sandbox, kwargs) 127 case _: 128 # Unknown tool, fall back 129 handler = self._original_handlers.get(tool_name) 130 if handler: 131 return await handler(**kwargs) 132 return f"Error: Unknown tool {tool_name}" 133 134 async def _sandbox_run_command( 135 self, 136 sandbox: DockerSandbox, 137 kwargs: dict[str, Any], 138 ) -> str: 139 """Run command in sandbox.""" 140 command = kwargs.get("command", "") 141 workdir = kwargs.get("working_directory") 142 timeout = kwargs.get("timeout") 143 144 result = await sandbox.execute( 145 command, 146 timeout=timeout, 147 workdir=workdir or sandbox.config.workspace_mount, 148 ) 149 150 if result.error: 151 return f"Error: {result.error}" 152 if result.timed_out: 153 return f"Command timed out after {result.duration_ms}ms" 154 155 output = result.stdout 156 if result.stderr: 157 output += f"\n[stderr]: {result.stderr}" 158 if result.exit_code != 0: 159 output += f"\n[exit code: {result.exit_code}]" 160 161 return output or "(no output)" 162 163 async def _sandbox_write_file( 164 self, 165 sandbox: DockerSandbox, 166 kwargs: dict[str, Any], 167 ) -> str: 168 """Write file in sandbox.""" 169 path = kwargs.get("path", "") 170 content = kwargs.get("content", "") 171 append = kwargs.get("append", False) 172 173 # Normalize path to workspace 174 if not path.startswith("/"): 175 path = f"{sandbox.config.workspace_mount}/{path}" 176 177 if append: 178 cmd = f"cat >> {path} << 'TIPHYS_EOF'\n{content}\nTIPHYS_EOF" 179 else: 180 # Create parent directories 181 parent = "/".join(path.rsplit("/", 1)[:-1]) 182 if parent: 183 await sandbox.execute(f"mkdir -p {parent}") 184 cmd = f"cat > {path} << 'TIPHYS_EOF'\n{content}\nTIPHYS_EOF" 185 186 result = await sandbox.execute(cmd) 187 188 if result.exit_code == 0: 189 action = "appended to" if append else "written to" 190 return f"Successfully {action} {path} ({len(content)} characters)" 191 return f"Error writing file: {result.stderr or result.error}" 192 193 async def _sandbox_read_file( 194 self, 195 sandbox: DockerSandbox, 196 kwargs: dict[str, Any], 197 ) -> str: 198 """Read file in sandbox.""" 199 path = kwargs.get("path", "") 200 start_line = kwargs.get("start_line", 1) 201 end_line = kwargs.get("end_line") 202 203 # Normalize path to workspace 204 if not path.startswith("/"): 205 path = f"{sandbox.config.workspace_mount}/{path}" 206 207 if start_line == 1 and end_line is None: 208 cmd = f"cat {path}" 209 elif end_line: 210 cmd = f"sed -n '{start_line},{end_line}p' {path}" 211 else: 212 cmd = f"tail -n +{start_line} {path}" 213 214 result = await sandbox.execute(cmd) 215 216 if result.exit_code == 0: 217 return result.stdout 218 return f"Error: {result.stderr or 'File not found'}" 219 220 async def _sandbox_list_directory( 221 self, 222 sandbox: DockerSandbox, 223 kwargs: dict[str, Any], 224 ) -> str: 225 """List directory in sandbox.""" 226 path = kwargs.get("path", ".") 227 show_hidden = kwargs.get("show_hidden", False) 228 recursive = kwargs.get("recursive", False) 229 230 # Normalize path to workspace 231 if not path.startswith("/"): 232 path = f"{sandbox.config.workspace_mount}/{path}" 233 234 cmd_parts = ["ls", "-l"] 235 if show_hidden: 236 cmd_parts.append("-a") 237 if recursive: 238 cmd_parts.append("-R") 239 cmd_parts.append(path) 240 241 result = await sandbox.execute(" ".join(cmd_parts)) 242 243 if result.exit_code == 0: 244 return f"Contents of {path}:\n\n{result.stdout}" 245 return f"Error: {result.stderr or 'Directory not found'}" 246 247 async def cleanup(self) -> None: 248 """Stop the sandbox.""" 249 if self._sandbox: 250 await self._sandbox.stop() 251 self._sandbox = None
Tool registry that wraps tools to run in a sandbox.
Intercepts tool execution and redirects it to a sandbox container.
27 def __init__( 28 self, 29 sandbox: DockerSandbox | None = None, 30 sandbox_config: SandboxConfig | None = None, 31 sandboxed_tools: set[str] | None = None, 32 ) -> None: 33 """ 34 Initialize sandboxed registry. 35 36 Args: 37 sandbox: Existing sandbox to use. 38 sandbox_config: Config for creating a new sandbox. 39 sandboxed_tools: Set of tool names to sandbox. None = all tools. 40 """ 41 super().__init__() 42 self._sandbox = sandbox 43 self._sandbox_config = sandbox_config or SandboxConfig() 44 self._sandboxed_tools = sandboxed_tools 45 self._original_handlers: dict[str, Callable[..., Awaitable[str]]] = {}
Initialize sandboxed registry.
Args: sandbox: Existing sandbox to use. sandbox_config: Config for creating a new sandbox. sandboxed_tools: Set of tool names to sandbox. None = all tools.
47 async def ensure_sandbox(self) -> DockerSandbox | None: 48 """Ensure sandbox is running.""" 49 if self._sandbox is None: 50 self._sandbox = await create_sandbox(config=self._sandbox_config) 51 return self._sandbox
Ensure sandbox is running.
53 def register(self, tool: ToolDefinition) -> None: 54 """Register a tool, wrapping it for sandbox execution if needed.""" 55 # Store original handler 56 self._original_handlers[tool.name] = tool.handler 57 58 # Check if this tool should be sandboxed 59 should_sandbox = self._sandboxed_tools is None or tool.name in self._sandboxed_tools 60 61 if should_sandbox and self._is_sandboxable(tool.name): 62 # Create sandboxed wrapper 63 wrapped_handler = self._create_sandbox_wrapper(tool) 64 tool = ToolDefinition( 65 name=tool.name, 66 description=tool.description + " (sandboxed)", 67 args_schema=tool.args_schema, 68 handler=wrapped_handler, 69 requires_permission=tool.requires_permission, 70 optional=tool.optional, 71 ) 72 73 super().register(tool)
Register a tool, wrapping it for sandbox execution if needed.
247 async def cleanup(self) -> None: 248 """Stop the sandbox.""" 249 if self._sandbox: 250 await self._sandbox.stop() 251 self._sandbox = None
Stop the sandbox.
Inherited Members
254def wrap_tool_for_sandbox( 255 tool: ToolDefinition, 256 sandbox: DockerSandbox, 257) -> ToolDefinition: 258 """ 259 Wrap a single tool for sandbox execution. 260 261 Args: 262 tool: The tool to wrap. 263 sandbox: The sandbox to use. 264 265 Returns: 266 A new ToolDefinition with sandboxed handler. 267 """ 268 original_handler = tool.handler 269 270 @wraps(original_handler) 271 async def sandboxed_handler(**kwargs: Any) -> str: 272 # For run_command, execute in sandbox 273 if tool.name == "run_command": 274 command = kwargs.get("command", "") 275 result = await sandbox.execute(command) 276 277 if result.error: 278 return f"Error: {result.error}" 279 280 output = result.stdout 281 if result.stderr: 282 output += f"\n[stderr]: {result.stderr}" 283 284 return output or "(no output)" 285 286 # For other tools, use original handler 287 return await original_handler(**kwargs) 288 289 return ToolDefinition( 290 name=tool.name, 291 description=tool.description, 292 args_schema=tool.args_schema, 293 handler=sandboxed_handler, 294 requires_permission=tool.requires_permission, 295 optional=tool.optional, 296 )
Wrap a single tool for sandbox execution.
Args: tool: The tool to wrap. sandbox: The sandbox to use.
Returns: A new ToolDefinition with sandboxed handler.