Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions lib/crewai-tools/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,9 @@ contextual = [
"contextual-client>=0.1.0",
"nest-asyncio>=1.6.0",
]
daytona = [
"daytona>=0.140.0",
]


[tool.uv]
Expand Down
8 changes: 8 additions & 0 deletions lib/crewai-tools/src/crewai_tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@
from crewai_tools.tools.databricks_query_tool.databricks_query_tool import (
DatabricksQueryTool,
)
from crewai_tools.tools.daytona_sandbox_tool import (
DaytonaExecTool,
DaytonaFileTool,
DaytonaPythonTool,
)
from crewai_tools.tools.directory_read_tool.directory_read_tool import (
DirectoryReadTool,
)
Expand Down Expand Up @@ -232,6 +237,9 @@
"DOCXSearchTool",
"DallETool",
"DatabricksQueryTool",
"DaytonaExecTool",
"DaytonaFileTool",
"DaytonaPythonTool",
"DirectoryReadTool",
"DirectorySearchTool",
"EXASearchTool",
Expand Down
8 changes: 8 additions & 0 deletions lib/crewai-tools/src/crewai_tools/tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@
from crewai_tools.tools.databricks_query_tool.databricks_query_tool import (
DatabricksQueryTool,
)
from crewai_tools.tools.daytona_sandbox_tool import (
DaytonaExecTool,
DaytonaFileTool,
DaytonaPythonTool,
)
from crewai_tools.tools.directory_read_tool.directory_read_tool import (
DirectoryReadTool,
)
Expand Down Expand Up @@ -217,6 +222,9 @@
"DOCXSearchTool",
"DallETool",
"DatabricksQueryTool",
"DaytonaExecTool",
"DaytonaFileTool",
"DaytonaPythonTool",
"DirectoryReadTool",
"DirectorySearchTool",
"EXASearchTool",
Expand Down
107 changes: 107 additions & 0 deletions lib/crewai-tools/src/crewai_tools/tools/daytona_sandbox_tool/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# Daytona Sandbox Tools

Run shell commands, execute Python, and manage files inside a [Daytona](https://www.daytona.io/) sandbox. Daytona provides isolated, ephemeral compute environments suitable for agent-driven code execution.

Three tools are provided so you can pick what the agent actually needs:

- **`DaytonaExecTool`** — run a shell command (`sandbox.process.exec`).
- **`DaytonaPythonTool`** — run a Python script (`sandbox.process.code_run`).
- **`DaytonaFileTool`** — read / write / list / delete files (`sandbox.fs.*`).

## Installation

```shell
uv add "crewai-tools[daytona]"
# or
pip install "crewai-tools[daytona]"
```

Set the API key:

```shell
export DAYTONA_API_KEY="..."
```

`DAYTONA_API_URL` and `DAYTONA_TARGET` are also respected if set.

## Sandbox lifecycle

All three tools share the same lifecycle controls from `DaytonaBaseTool`:

| Mode | When the sandbox is created | When it is deleted |
| --- | --- | --- |
| **Ephemeral** (default, `persistent=False`) | On every `_run` call | At the end of that same call |
| **Persistent** (`persistent=True`) | Lazily on first use | At process exit (via `atexit`), or manually via `tool.close()` |
| **Attach** (`sandbox_id="…"`) | Never — the tool attaches to an existing sandbox | Never — the tool will not delete a sandbox it did not create |

Ephemeral mode is the safe default: nothing leaks if the agent forgets to clean up. Use persistent mode when you want filesystem state or installed packages to carry across steps — this is typical when pairing `DaytonaFileTool` with `DaytonaExecTool`.

## Examples

### One-shot Python execution (ephemeral)

```python
from crewai_tools import DaytonaPythonTool

tool = DaytonaPythonTool()
result = tool.run(code="print(sum(range(10)))")
```

### Multi-step shell session (persistent)

```python
from crewai_tools import DaytonaExecTool, DaytonaFileTool

exec_tool = DaytonaExecTool(persistent=True)
file_tool = DaytonaFileTool(persistent=True)

# Agent writes a script, then runs it — both share the same sandbox instance
# because they each keep their own persistent sandbox. If you need the *same*
# sandbox across two tools, create one tool, grab the sandbox id via
# `tool._persistent_sandbox.id`, and pass it to the other via `sandbox_id=...`.
```

### Attach to an existing sandbox

```python
from crewai_tools import DaytonaExecTool

tool = DaytonaExecTool(sandbox_id="my-long-lived-sandbox")
```

### Custom create params

Pass Daytona's `CreateSandboxFromSnapshotParams` kwargs via `create_params`:

```python
tool = DaytonaExecTool(
persistent=True,
create_params={
"language": "python",
"env_vars": {"MY_FLAG": "1"},
"labels": {"owner": "crewai-agent"},
},
)
```

## Tool arguments

### `DaytonaExecTool`
- `command: str` — shell command to run.
- `cwd: str | None` — working directory.
- `env: dict[str, str] | None` — extra env vars for this command.
- `timeout: int | None` — seconds.

### `DaytonaPythonTool`
- `code: str` — Python source to execute.
- `argv: list[str] | None` — argv forwarded via `CodeRunParams`.
- `env: dict[str, str] | None` — env vars forwarded via `CodeRunParams`.
- `timeout: int | None` — seconds.

### `DaytonaFileTool`
- `action: "read" | "write" | "list" | "delete" | "mkdir" | "info"`
- `path: str` — absolute path inside the sandbox.
- `content: str | None` — required for `write`.
- `binary: bool` — if `True`, `content` is base64 on write / returned as base64 on read.
- `recursive: bool` — for `delete`, removes directories recursively.
- `mode: str` — for `mkdir`, octal permission string (default `"0755"`).
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from crewai_tools.tools.daytona_sandbox_tool.daytona_base_tool import DaytonaBaseTool
from crewai_tools.tools.daytona_sandbox_tool.daytona_exec_tool import DaytonaExecTool
from crewai_tools.tools.daytona_sandbox_tool.daytona_file_tool import DaytonaFileTool
from crewai_tools.tools.daytona_sandbox_tool.daytona_python_tool import (
DaytonaPythonTool,
)

__all__ = [
"DaytonaBaseTool",
"DaytonaExecTool",
"DaytonaFileTool",
"DaytonaPythonTool",
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
from __future__ import annotations

import atexit
import os
import threading
from typing import Any, ClassVar

from crewai.tools import BaseTool, EnvVar
from pydantic import ConfigDict, Field, PrivateAttr


class DaytonaBaseTool(BaseTool):
"""Shared base for tools that act on a Daytona sandbox.

Lifecycle modes:
- persistent=False (default): create a fresh sandbox per `_run` call and
delete it when the call returns. Safer and stateless — nothing leaks if
the agent forgets cleanup.
- persistent=True: lazily create a single sandbox on first use, cache it
on the instance, and register an atexit hook to delete it at process
exit. Cheaper across many calls and lets files/state carry over.
- sandbox_id=<existing>: attach to a sandbox the caller already owns.
Never deleted by the tool.
"""

model_config = ConfigDict(arbitrary_types_allowed=True)

package_dependencies: list[str] = Field(default_factory=lambda: ["daytona"])

api_key: str | None = Field(
default_factory=lambda: os.getenv("DAYTONA_API_KEY"),
description="Daytona API key. Falls back to DAYTONA_API_KEY env var.",
json_schema_extra={"required": False},
)
api_url: str | None = Field(
default_factory=lambda: os.getenv("DAYTONA_API_URL"),
description="Daytona API URL override. Falls back to DAYTONA_API_URL env var.",
json_schema_extra={"required": False},
)
target: str | None = Field(
default_factory=lambda: os.getenv("DAYTONA_TARGET"),
description="Daytona target region. Falls back to DAYTONA_TARGET env var.",
json_schema_extra={"required": False},
)

persistent: bool = Field(
default=False,
description=(
"If True, reuse one sandbox across all calls to this tool instance "
"and delete it at process exit. Default False creates and deletes a "
"fresh sandbox per call."
),
)
sandbox_id: str | None = Field(
default=None,
description=(
"Attach to an existing sandbox by id or name instead of creating a "
"new one. The tool will never delete a sandbox it did not create."
),
)
create_params: dict[str, Any] | None = Field(
default=None,
description=(
"Optional kwargs forwarded to CreateSandboxFromSnapshotParams when "
"creating a sandbox (e.g. language, snapshot, env_vars, labels)."
),
)
sandbox_timeout: float = Field(
default=60.0,
description="Timeout in seconds for sandbox create/delete operations.",
)

env_vars: list[EnvVar] = Field(
default_factory=lambda: [
EnvVar(
name="DAYTONA_API_KEY",
description="API key for Daytona sandbox service",
required=False,
),
EnvVar(
name="DAYTONA_API_URL",
description="Daytona API base URL (optional)",
required=False,
),
EnvVar(
name="DAYTONA_TARGET",
description="Daytona target region (optional)",
required=False,
),
]
)

_client: Any | None = PrivateAttr(default=None)
_persistent_sandbox: Any | None = PrivateAttr(default=None)
_lock: threading.Lock = PrivateAttr(default_factory=threading.Lock)
_cleanup_registered: bool = PrivateAttr(default=False)

_sdk_cache: ClassVar[dict[str, Any]] = {}

@classmethod
def _import_sdk(cls) -> dict[str, Any]:
if cls._sdk_cache:
return cls._sdk_cache
try:
from daytona import ( # type: ignore[import-not-found]
CreateSandboxFromSnapshotParams,
Daytona,
DaytonaConfig,
)
except ImportError as exc:
raise ImportError(
"The 'daytona' package is required for Daytona sandbox tools. "
"Install it with: uv add daytona (or) pip install daytona"
) from exc
cls._sdk_cache = {
"Daytona": Daytona,
"DaytonaConfig": DaytonaConfig,
"CreateSandboxFromSnapshotParams": CreateSandboxFromSnapshotParams,
}
return cls._sdk_cache

def _get_client(self) -> Any:
if self._client is not None:
return self._client
sdk = self._import_sdk()
config_kwargs: dict[str, Any] = {}
if self.api_key:
config_kwargs["api_key"] = self.api_key
if self.api_url:
config_kwargs["api_url"] = self.api_url
if self.target:
config_kwargs["target"] = self.target
config = sdk["DaytonaConfig"](**config_kwargs) if config_kwargs else None
self._client = sdk["Daytona"](config) if config else sdk["Daytona"]()
return self._client

def _build_create_params(self) -> Any | None:
if not self.create_params:
return None
sdk = self._import_sdk()
return sdk["CreateSandboxFromSnapshotParams"](**self.create_params)

def _acquire_sandbox(self) -> tuple[Any, bool]:
"""Return (sandbox, should_delete_after_use)."""
client = self._get_client()

if self.sandbox_id:
return client.get(self.sandbox_id), False

if self.persistent:
with self._lock:
if self._persistent_sandbox is None:
self._persistent_sandbox = client.create(
self._build_create_params(),
timeout=self.sandbox_timeout,
)
if not self._cleanup_registered:
atexit.register(self.close)
self._cleanup_registered = True
return self._persistent_sandbox, False

sandbox = client.create(
self._build_create_params(),
timeout=self.sandbox_timeout,
)
return sandbox, True

def _release_sandbox(self, sandbox: Any, should_delete: bool) -> None:
if not should_delete:
return
try:
sandbox.delete(timeout=self.sandbox_timeout)
except Exception:
pass
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sandbox deletion uses wrong API, causing silent leaks

High Severity

Both _release_sandbox and close call sandbox.delete(timeout=...), but the Daytona Python SDK's sync API deletes sandboxes via the client: daytona_client.delete(sandbox). Since these calls are wrapped in bare except Exception: pass, any resulting AttributeError is silently swallowed, causing sandboxes to leak in both ephemeral and persistent modes without any error indication.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 69e27e7. Configure here.


def close(self) -> None:
"""Delete the cached persistent sandbox if one exists."""
with self._lock:
sandbox = self._persistent_sandbox
self._persistent_sandbox = None
if sandbox is None:
return
try:
sandbox.delete(timeout=self.sandbox_timeout)
except Exception:
pass
Loading
Loading