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]
__version__ = '0.1.0'
class TiphysConfig(pydantic_settings.main.BaseSettings):
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.

host: str = $TIPHYS_HOST

Server bind address

port: int = 8000

Server port

cors_origins: list[str] = PydanticUndefined

Allowed CORS origins (use ['*'] for development only)

llm: LLMSettings = PydanticUndefined

LLM provider configuration

model: str = $TIPHYS_LLM__MODEL

LLM model identifier (LiteLLM format)

api_base: str | None = None

Custom API base URL for LLM provider

api_key: str | None = None

API key for LLM provider

agent_id: str = 'default'

Default agent identifier

agent_name: str = 'Tiphys'

Agent display name

dm_scope: DmScope = <DmScope.MAIN: 'main'>

Session scoping mode for DMs

data_dir: pathlib.Path = PydanticUndefined

Data storage directory

plugins_dir: pathlib.Path = PosixPath('plugins')

Plugins directory

skills_dir: pathlib.Path = PosixPath('skills')

Skills directory

exec_mode: Literal['deny', 'allowlist', 'full'] = $TIPHYS_EXEC_MODE

Command execution mode

allowed_commands: list[str] = PydanticUndefined

Allowed commands in allowlist mode

log_level: tiphys.config.LogLevel = <LogLevel.INFO: 'INFO'>

Logging level

log_json: bool = False

Output logs as JSON

telegram_token: str | None = None

Telegram bot token

discord_token: str | None = None

Discord bot token

sandbox: SandboxSettings = PydanticUndefined

Sandbox configuration for secure execution

agents: list[tiphys.config.AgentConfig] = PydanticUndefined

Additional agent configurations

default_agent_id: str | None = None

Default agent ID (uses agent_id if not set)

budget: tiphys.config.BudgetConfig = PydanticUndefined

Castor budget configuration

default_channel_config: tiphys.config.ChannelConfig = PydanticUndefined

Default configuration for all channels

channels: dict[str, tiphys.config.ChannelConfig] = PydanticUndefined

Per-channel configuration overrides

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

def get_channel_config(self, channel_name: str) -> tiphys.config.ChannelConfig:
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")

class DmScope(builtins.str, enum.Enum):
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.

MAIN = <DmScope.MAIN: 'main'>
PER_PEER = <DmScope.PER_PEER: 'per_peer'>
PER_CHANNEL_PEER = <DmScope.PER_CHANNEL_PEER: 'per_channel_peer'>
FULL = <DmScope.FULL: 'full'>
class LLMProvider(builtins.str, enum.Enum):
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.

OPENROUTER = $TIPHYS_LLM__PROVIDER
OPENAI = <LLMProvider.OPENAI: 'openai'>
ANTHROPIC = <LLMProvider.ANTHROPIC: 'anthropic'>
OLLAMA = <LLMProvider.OLLAMA: 'ollama'>
CUSTOM = <LLMProvider.CUSTOM: 'custom'>
class LLMSettings(pydantic.main.BaseModel):
 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.

provider: LLMProvider = $TIPHYS_LLM__PROVIDER

LLM provider to use

model: str = $TIPHYS_LLM__MODEL

Model identifier (provider-specific format)

api_key: str | None = None

API key for the provider (or use env vars)

api_base: str | None = None

Custom API base URL (auto-set for known providers)

temperature: float = 0.7

Default sampling temperature

max_tokens: int | None = None

Default max tokens to generate

openrouter_site_url: str | None = None

Your site URL for OpenRouter rankings

openrouter_app_name: str | None = 'Tiphys'

Your app name for OpenRouter rankings

def get_api_base(self) -> str | None:
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.

def get_model_id(self) -> str:
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.

def get_extra_headers(self) -> dict[str, str]:
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.

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

enabled: bool = False

Enable sandboxed execution

image: str = 'python:3.12-slim'

Docker image

memory_limit: str = '512m'

Memory limit

cpu_limit: float = 1.0

CPU limit

timeout_seconds: int = 300

Timeout

network_enabled: bool = False

Allow network access

sandboxed_tools: list[str] = PydanticUndefined

Tools to run in sandbox

def load_config() -> TiphysConfig:
417def load_config() -> TiphysConfig:
418    """Load configuration from environment and files."""
419    return TiphysConfig()

Load configuration from environment and files.

app = <fastapi.applications.FastAPI object>
def create_app( config: TiphysConfig | None = None) -> fastapi.applications.FastAPI:
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.

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

event_id: str = PydanticUndefined
timestamp: datetime.datetime = PydanticUndefined
class ThoughtEvent(tiphys.BaseEvent):
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).

type: Literal['thought'] = 'thought'
content: str = PydanticUndefined
agent_id: str = PydanticUndefined
Inherited Members
BaseEvent
event_id
timestamp
class TextEvent(tiphys.BaseEvent):
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.

type: Literal['text'] = 'text'
content: str = PydanticUndefined
agent_id: str = PydanticUndefined
is_final: bool = False
Inherited Members
BaseEvent
event_id
timestamp
class ToolCallEvent(tiphys.BaseEvent):
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.

type: Literal['tool_call'] = 'tool_call'
tool_name: str = PydanticUndefined
arguments: dict[str, object] = PydanticUndefined
tool_call_id: str = PydanticUndefined
agent_id: str = PydanticUndefined
Inherited Members
BaseEvent
event_id
timestamp
class ToolResultEvent(tiphys.BaseEvent):
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.

type: Literal['tool_result'] = 'tool_result'
tool_call_id: str = PydanticUndefined
tool_name: str = PydanticUndefined
result: str = PydanticUndefined
success: bool = PydanticUndefined
agent_id: str = PydanticUndefined
Inherited Members
BaseEvent
event_id
timestamp
class PermissionRequestEvent(tiphys.BaseEvent):
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.

type: Literal['permission_request'] = 'permission_request'
request_id: str = PydanticUndefined
action: str = PydanticUndefined
details: str = PydanticUndefined
agent_id: str = PydanticUndefined
Inherited Members
BaseEvent
event_id
timestamp
class PermissionResponseEvent(tiphys.BaseEvent):
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.

type: Literal['permission_response'] = 'permission_response'
request_id: str = PydanticUndefined
approved: bool = PydanticUndefined
feedback: str | None = None
Inherited Members
BaseEvent
event_id
timestamp
class SessionStartEvent(tiphys.BaseEvent):
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.

type: Literal['session:start'] = 'session:start'
session_key: str = PydanticUndefined
agent_id: str = PydanticUndefined
Inherited Members
BaseEvent
event_id
timestamp
class SessionEndEvent(tiphys.BaseEvent):
 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.

type: Literal['session:end'] = 'session:end'
session_key: str = PydanticUndefined
duration_ms: int = PydanticUndefined
Inherited Members
BaseEvent
event_id
timestamp
class ErrorEvent(tiphys.BaseEvent):
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.

type: Literal['error'] = 'error'
message: str = PydanticUndefined
code: str | None = None
agent_id: str | None = None
Inherited Members
BaseEvent
event_id
timestamp
class StatusEvent(tiphys.BaseEvent):
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.

type: Literal['status'] = 'status'
status: Literal['ready', 'busy', 'error'] = PydanticUndefined
message: str | None = None
Inherited Members
BaseEvent
event_id
timestamp
@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
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.

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.

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.