chela
Tiphys: A local-first, extensible AI agent framework.
1""" 2Tiphys: A local-first, extensible AI agent framework. 3""" 4 5from tiphys.config import ( 6 TiphysConfig, 7 DmScope, 8 LLMProvider, 9 LLMSettings, 10 SandboxSettings, 11 load_config, 12) 13from tiphys.events import ( 14 BaseEvent, 15 ErrorEvent, 16 EventType, 17 PermissionRequestEvent, 18 PermissionResponseEvent, 19 SessionEndEvent, 20 SessionStartEvent, 21 StatusEvent, 22 TextEvent, 23 ThoughtEvent, 24 ToolCallEvent, 25 ToolResultEvent, 26) 27from tiphys.main import app, cli, create_app 28from tiphys.sandbox import ( 29 DockerSandbox, 30 SandboxConfig, 31 SandboxedToolRegistry, 32 SandboxResult, 33 create_sandbox, 34 get_sandbox_manager, 35 wrap_tool_for_sandbox, 36) 37 38__version__ = "0.1.0" 39 40__all__ = [ 41 # Version 42 "__version__", 43 # Config 44 "TiphysConfig", 45 "DmScope", 46 "LLMProvider", 47 "LLMSettings", 48 "SandboxSettings", 49 "load_config", 50 # App 51 "app", 52 "cli", 53 "create_app", 54 # Events 55 "BaseEvent", 56 "EventType", 57 "ThoughtEvent", 58 "TextEvent", 59 "ToolCallEvent", 60 "ToolResultEvent", 61 "PermissionRequestEvent", 62 "PermissionResponseEvent", 63 "SessionStartEvent", 64 "SessionEndEvent", 65 "ErrorEvent", 66 "StatusEvent", 67 # Sandbox 68 "DockerSandbox", 69 "SandboxConfig", 70 "SandboxResult", 71 "SandboxedToolRegistry", 72 "create_sandbox", 73 "get_sandbox_manager", 74 "wrap_tool_for_sandbox", 75]
259class TiphysConfig(BaseSettings): 260 """Main configuration for Tiphys.""" 261 262 model_config = SettingsConfigDict( 263 env_prefix="TIPHYS_", 264 env_file=".env", 265 env_file_encoding="utf-8", 266 extra="ignore", 267 ) 268 269 # Server 270 host: str = Field(default="127.0.0.1", description="Server bind address") 271 port: int = Field(default=8000, ge=1, le=65535, description="Server port") 272 cors_origins: list[str] = Field( 273 default_factory=lambda: ["http://localhost:8000", "http://127.0.0.1:8000"], 274 description="Allowed CORS origins (use ['*'] for development only)", 275 ) 276 277 # LLM (new structured settings) 278 llm: LLMSettings = Field( 279 default_factory=LLMSettings, 280 description="LLM provider configuration", 281 ) 282 283 # Legacy LLM fields (for backwards compatibility) 284 model: str = Field( 285 default="anthropic/claude-3.5-sonnet", 286 description="LLM model identifier (LiteLLM format)", 287 ) 288 api_base: str | None = Field( 289 default=None, 290 description="Custom API base URL for LLM provider", 291 ) 292 api_key: str | None = Field( 293 default=None, 294 description="API key for LLM provider", 295 ) 296 297 # Agent 298 agent_id: str = Field(default="default", description="Default agent identifier") 299 agent_name: str = Field(default="Tiphys", description="Agent display name") 300 dm_scope: DmScope = Field( 301 default=DmScope.MAIN, 302 description="Session scoping mode for DMs", 303 ) 304 305 # Paths 306 data_dir: Path = Field( 307 default_factory=lambda: Path.home() / ".tiphys", 308 description="Data storage directory", 309 ) 310 plugins_dir: Path = Field( 311 default=Path("./plugins"), 312 description="Plugins directory", 313 ) 314 skills_dir: Path = Field( 315 default=Path("./skills"), 316 description="Skills directory", 317 ) 318 319 # Security 320 exec_mode: Literal["deny", "allowlist", "full"] = Field( 321 default="allowlist", 322 description="Command execution mode", 323 ) 324 allowed_commands: list[str] = Field( 325 default_factory=lambda: ["ls", "cat", "grep", "find", "git", "python", "pip", "uv"], 326 description="Allowed commands in allowlist mode", 327 ) 328 329 # Logging 330 log_level: LogLevel = Field( 331 default=LogLevel.INFO, 332 description="Logging level", 333 ) 334 log_json: bool = Field( 335 default=False, 336 description="Output logs as JSON", 337 ) 338 339 # Channels 340 telegram_token: str | None = Field( 341 default=None, 342 description="Telegram bot token", 343 ) 344 discord_token: str | None = Field( 345 default=None, 346 description="Discord bot token", 347 ) 348 349 # Sandbox 350 sandbox: SandboxSettings = Field( 351 default_factory=SandboxSettings, 352 description="Sandbox configuration for secure execution", 353 ) 354 355 # Multi-agent configuration 356 agents: list[AgentConfig] = Field( 357 default_factory=list, 358 description="Additional agent configurations", 359 ) 360 default_agent_id: str | None = Field( 361 default=None, 362 description="Default agent ID (uses agent_id if not set)", 363 ) 364 365 # Castor kernel budget configuration 366 budget: BudgetConfig = Field( 367 default_factory=BudgetConfig, 368 description="Castor budget configuration", 369 ) 370 371 # Per-channel configuration (Phase R6) 372 default_channel_config: ChannelConfig = Field( 373 default_factory=ChannelConfig, 374 description="Default configuration for all channels", 375 ) 376 channels: dict[str, ChannelConfig] = Field( 377 default_factory=dict, 378 description="Per-channel configuration overrides", 379 ) 380 381 def ensure_dirs(self) -> None: 382 """Create required directories if they don't exist.""" 383 self.data_dir.mkdir(parents=True, exist_ok=True) 384 (self.data_dir / "sessions").mkdir(exist_ok=True) 385 (self.data_dir / "logs").mkdir(exist_ok=True) 386 387 def get_channel_config(self, channel_name: str) -> ChannelConfig: 388 """ 389 Get merged configuration for a specific channel. 390 391 If the channel has specific configuration, it is merged with 392 the default configuration (channel settings override defaults). 393 394 Args: 395 channel_name: The channel name (e.g., "webchat", "telegram"). 396 397 Returns: 398 Merged ChannelConfig with channel-specific overrides applied. 399 400 Example: 401 config = load_config() 402 webchat = config.get_channel_config("webchat") 403 print(f"Rate limit: {webchat.rate_limit_requests_per_minute}/min") 404 """ 405 # If no channel-specific config, return defaults 406 if channel_name not in self.channels: 407 return self.default_channel_config 408 409 # Merge channel config with defaults 410 # Channel settings override default settings 411 defaults = self.default_channel_config.model_dump() 412 channel_overrides = self.channels[channel_name].model_dump(exclude_unset=True) 413 merged = {**defaults, **channel_overrides} 414 return ChannelConfig(**merged)
Main configuration for Tiphys.
381 def ensure_dirs(self) -> None: 382 """Create required directories if they don't exist.""" 383 self.data_dir.mkdir(parents=True, exist_ok=True) 384 (self.data_dir / "sessions").mkdir(exist_ok=True) 385 (self.data_dir / "logs").mkdir(exist_ok=True)
Create required directories if they don't exist.
387 def get_channel_config(self, channel_name: str) -> ChannelConfig: 388 """ 389 Get merged configuration for a specific channel. 390 391 If the channel has specific configuration, it is merged with 392 the default configuration (channel settings override defaults). 393 394 Args: 395 channel_name: The channel name (e.g., "webchat", "telegram"). 396 397 Returns: 398 Merged ChannelConfig with channel-specific overrides applied. 399 400 Example: 401 config = load_config() 402 webchat = config.get_channel_config("webchat") 403 print(f"Rate limit: {webchat.rate_limit_requests_per_minute}/min") 404 """ 405 # If no channel-specific config, return defaults 406 if channel_name not in self.channels: 407 return self.default_channel_config 408 409 # Merge channel config with defaults 410 # Channel settings override default settings 411 defaults = self.default_channel_config.model_dump() 412 channel_overrides = self.channels[channel_name].model_dump(exclude_unset=True) 413 merged = {**defaults, **channel_overrides} 414 return ChannelConfig(**merged)
Get merged configuration for a specific channel.
If the channel has specific configuration, it is merged with the default configuration (channel settings override defaults).
Args: channel_name: The channel name (e.g., "webchat", "telegram").
Returns: Merged ChannelConfig with channel-specific overrides applied.
Example: config = load_config() webchat = config.get_channel_config("webchat") print(f"Rate limit: {webchat.rate_limit_requests_per_minute}/min")
29class DmScope(str, Enum): 30 """DM session scoping modes.""" 31 32 MAIN = "main" # All DMs share context 33 PER_PEER = "per_peer" # Each user gets own context 34 PER_CHANNEL_PEER = "per_channel_peer" 35 FULL = "full" # Maximum granularity
DM session scoping modes.
19class LLMProvider(str, Enum): 20 """Supported LLM providers.""" 21 22 OPENROUTER = "openrouter" # OpenRouter (multi-model gateway) 23 OPENAI = "openai" # OpenAI direct 24 ANTHROPIC = "anthropic" # Anthropic direct 25 OLLAMA = "ollama" # Local Ollama 26 CUSTOM = "custom" # Custom API endpoint
Supported LLM providers.
47class LLMSettings(BaseModel): 48 """LLM provider configuration.""" 49 50 provider: LLMProvider = Field( 51 default=LLMProvider.OPENROUTER, 52 description="LLM provider to use", 53 ) 54 model: str = Field( 55 default="anthropic/claude-3.5-sonnet", 56 description="Model identifier (provider-specific format)", 57 ) 58 api_key: str | None = Field( 59 default=None, 60 description="API key for the provider (or use env vars)", 61 ) 62 api_base: str | None = Field( 63 default=None, 64 description="Custom API base URL (auto-set for known providers)", 65 ) 66 temperature: float = Field( 67 default=0.7, 68 ge=0.0, 69 le=2.0, 70 description="Default sampling temperature", 71 ) 72 max_tokens: int | None = Field( 73 default=None, 74 description="Default max tokens to generate", 75 ) 76 77 # OpenRouter-specific settings 78 openrouter_site_url: str | None = Field( 79 default=None, 80 description="Your site URL for OpenRouter rankings", 81 ) 82 openrouter_app_name: str | None = Field( 83 default="Tiphys", 84 description="Your app name for OpenRouter rankings", 85 ) 86 87 def get_api_base(self) -> str | None: 88 """Get the API base URL for the configured provider.""" 89 if self.api_base: 90 return self.api_base 91 92 match self.provider: 93 case LLMProvider.OPENROUTER: 94 return "https://openrouter.ai/api/v1" 95 case LLMProvider.OLLAMA: 96 return "http://localhost:11434" 97 case _: 98 return None 99 100 def get_model_id(self) -> str: 101 """Get the full model ID for LiteLLM.""" 102 # For OpenRouter, LiteLLM expects "openrouter/<model>" 103 if self.provider == LLMProvider.OPENROUTER: 104 if not self.model.startswith("openrouter/"): 105 return f"openrouter/{self.model}" 106 elif self.provider == LLMProvider.OLLAMA and not self.model.startswith("ollama/"): 107 return f"ollama/{self.model}" 108 return self.model 109 110 def get_extra_headers(self) -> dict[str, str]: 111 """Get extra headers for API requests.""" 112 headers: dict[str, str] = {} 113 114 if self.provider == LLMProvider.OPENROUTER: 115 if self.openrouter_site_url: 116 headers["HTTP-Referer"] = self.openrouter_site_url 117 if self.openrouter_app_name: 118 headers["X-Title"] = self.openrouter_app_name 119 120 return headers
LLM provider configuration.
87 def get_api_base(self) -> str | None: 88 """Get the API base URL for the configured provider.""" 89 if self.api_base: 90 return self.api_base 91 92 match self.provider: 93 case LLMProvider.OPENROUTER: 94 return "https://openrouter.ai/api/v1" 95 case LLMProvider.OLLAMA: 96 return "http://localhost:11434" 97 case _: 98 return None
Get the API base URL for the configured provider.
100 def get_model_id(self) -> str: 101 """Get the full model ID for LiteLLM.""" 102 # For OpenRouter, LiteLLM expects "openrouter/<model>" 103 if self.provider == LLMProvider.OPENROUTER: 104 if not self.model.startswith("openrouter/"): 105 return f"openrouter/{self.model}" 106 elif self.provider == LLMProvider.OLLAMA and not self.model.startswith("ollama/"): 107 return f"ollama/{self.model}" 108 return self.model
Get the full model ID for LiteLLM.
110 def get_extra_headers(self) -> dict[str, str]: 111 """Get extra headers for API requests.""" 112 headers: dict[str, str] = {} 113 114 if self.provider == LLMProvider.OPENROUTER: 115 if self.openrouter_site_url: 116 headers["HTTP-Referer"] = self.openrouter_site_url 117 if self.openrouter_app_name: 118 headers["X-Title"] = self.openrouter_app_name 119 120 return headers
Get extra headers for API requests.
123class SandboxSettings(BaseModel): 124 """Sandbox configuration for secure execution.""" 125 126 enabled: bool = Field(default=False, description="Enable sandboxed execution") 127 image: str = Field(default="python:3.12-slim", description="Docker image") 128 memory_limit: str = Field(default="512m", description="Memory limit") 129 cpu_limit: float = Field(default=1.0, ge=0.1, le=16.0, description="CPU limit") 130 timeout_seconds: int = Field(default=300, ge=10, le=3600, description="Timeout") 131 network_enabled: bool = Field(default=False, description="Allow network access") 132 sandboxed_tools: list[str] = Field( 133 default_factory=lambda: ["run_command"], 134 description="Tools to run in sandbox", 135 )
Sandbox configuration for secure execution.
417def load_config() -> TiphysConfig: 418 """Load configuration from environment and files.""" 419 return TiphysConfig()
Load configuration from environment and files.
100def create_app(config: TiphysConfig | None = None) -> FastAPI: 101 """ 102 Create and configure the FastAPI application. 103 104 Args: 105 config: Optional configuration. If not provided, loads from environment. 106 107 Returns: 108 Configured FastAPI application. 109 """ 110 if config is None: 111 config = load_config() 112 113 app = FastAPI( 114 title="Tiphys", 115 description="Local-first, extensible AI agent framework", 116 version="0.1.0", 117 lifespan=lifespan, 118 ) 119 120 # CORS middleware 121 app.add_middleware( 122 CORSMiddleware, 123 allow_origins=config.cors_origins, 124 allow_credentials=True, 125 allow_methods=["*"], 126 allow_headers=["*"], 127 ) 128 129 # Include API routes 130 app.include_router(router) 131 132 # Serve static files 133 if STATIC_DIR.exists(): 134 app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") 135 136 @app.get("/") 137 async def serve_index() -> FileResponse: 138 """Serve the main HTML page.""" 139 return FileResponse(STATIC_DIR / "index.html") 140 141 return app
Create and configure the FastAPI application.
Args: config: Optional configuration. If not provided, loads from environment.
Returns: Configured FastAPI application.
15class BaseEvent(BaseModel): 16 """Base class for all events.""" 17 18 event_id: str = Field(default_factory=lambda: str(uuid4())) 19 timestamp: datetime = Field(default_factory=datetime.utcnow)
Base class for all events.
25class ThoughtEvent(BaseEvent): 26 """Agent's internal reasoning (may be hidden from user).""" 27 28 type: Literal["thought"] = "thought" 29 content: str 30 agent_id: str
Agent's internal reasoning (may be hidden from user).
33class TextEvent(BaseEvent): 34 """Text output from agent.""" 35 36 type: Literal["text"] = "text" 37 content: str 38 agent_id: str 39 is_final: bool = False
Text output from agent.
42class ToolCallEvent(BaseEvent): 43 """Agent is calling a tool.""" 44 45 type: Literal["tool_call"] = "tool_call" 46 tool_name: str 47 arguments: dict[str, object] 48 tool_call_id: str = Field(default_factory=lambda: str(uuid4())) 49 agent_id: str
Agent is calling a tool.
52class ToolResultEvent(BaseEvent): 53 """Result from a tool execution.""" 54 55 type: Literal["tool_result"] = "tool_result" 56 tool_call_id: str 57 tool_name: str 58 result: str 59 success: bool 60 agent_id: str
Result from a tool execution.
66class PermissionRequestEvent(BaseEvent): 67 """Requesting user approval for an action.""" 68 69 type: Literal["permission_request"] = "permission_request" 70 request_id: str = Field(default_factory=lambda: str(uuid4())) 71 action: str 72 details: str 73 agent_id: str
Requesting user approval for an action.
76class PermissionResponseEvent(BaseEvent): 77 """User's response to a permission request.""" 78 79 type: Literal["permission_response"] = "permission_response" 80 request_id: str 81 approved: bool 82 feedback: str | None = None
User's response to a permission request.
88class SessionStartEvent(BaseEvent): 89 """A session has started.""" 90 91 type: Literal["session:start"] = "session:start" 92 session_key: str 93 agent_id: str
A session has started.
96class SessionEndEvent(BaseEvent): 97 """A session has ended.""" 98 99 type: Literal["session:end"] = "session:end" 100 session_key: str 101 duration_ms: int
A session has ended.
107class ErrorEvent(BaseEvent): 108 """An error occurred.""" 109 110 type: Literal["error"] = "error" 111 message: str 112 code: str | None = None 113 agent_id: str | None = None
An error occurred.
116class StatusEvent(BaseEvent): 117 """System status update.""" 118 119 type: Literal["status"] = "status" 120 status: Literal["ready", "busy", "error"] 121 message: str | None = None
System status update.
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.
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
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.
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.