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]
@dataclass
class DockerSandbox:
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.

DockerSandbox( config: SandboxConfig, container_id: str | None = None, status: tiphys.sandbox.docker.SandboxStatus = <SandboxStatus.CREATED: 'created'>, _docker_available: bool | None = None)
config: SandboxConfig
container_id: str | None = None
status: tiphys.sandbox.docker.SandboxStatus = <SandboxStatus.CREATED: 'created'>
async def start(self) -> bool:
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.

async def stop(self) -> bool:
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.

async def execute( self, command: str, timeout: int | None = None, workdir: str | None = None) -> SandboxResult:
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.

async def write_file(self, path: str, content: str) -> bool:
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.

async def read_file(self, path: str) -> str | None:
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.

class SandboxConfig(pydantic.main.BaseModel):
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.

image: str = 'python:3.12-slim'

Docker image to use

name_prefix: str = 'chela-sandbox'

Prefix for container names

memory_limit: str = '512m'

Memory limit (e.g., '512m', '1g')

cpu_limit: float = 1.0

CPU limit (number of cores)

timeout_seconds: int = 300

Maximum execution time in seconds

network_enabled: bool = False

Whether to allow network access

network_mode: str = 'none'

Docker network mode

workspace_path: pathlib.Path | None = None

Host path to mount as workspace

workspace_mount: str = '/workspace'

Container path for workspace mount

read_only_workspace: bool = False

Mount workspace as read-only

privileged: bool = False

Run container in privileged mode (NOT recommended)

user: str = 'nobody'

User to run commands as

drop_capabilities: list[str] = PydanticUndefined

Capabilities to drop

security_opt: list[str] = PydanticUndefined

Security options

@dataclass
class SandboxResult:
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.

SandboxResult( exit_code: int, stdout: str, stderr: str, timed_out: bool = False, error: str | None = None, duration_ms: int = 0)
exit_code: int
stdout: str
stderr: str
timed_out: bool = False
error: str | None = None
duration_ms: int = 0
async def create_sandbox( name: str | None = None, config: SandboxConfig | None = None) -> DockerSandbox:
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.

def get_sandbox_manager() -> tiphys.sandbox.docker.SandboxManager:
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.

class SandboxedToolRegistry(tiphys.tools.base.ToolRegistry):
 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.

SandboxedToolRegistry( sandbox: DockerSandbox | None = None, sandbox_config: SandboxConfig | None = None, sandboxed_tools: set[str] | None = None)
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.

async def ensure_sandbox(self) -> DockerSandbox | None:
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.

def register(self, tool: tiphys.tools.base.ToolDefinition) -> None:
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.

async def cleanup(self) -> None:
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.

def wrap_tool_for_sandbox( tool: tiphys.tools.base.ToolDefinition, sandbox: DockerSandbox) -> tiphys.tools.base.ToolDefinition:
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.