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
4 changes: 4 additions & 0 deletions lib/crewai-tools/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,10 @@ contextual = [
"contextual-client>=0.1.0",
"nest-asyncio>=1.6.0",
]
boxlite = [
"boxlite[sync]>=0.8.2,<1.0",
]

daytona = [
"daytona~=0.140.0",
]
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 @@ -10,6 +10,11 @@
from crewai_tools.tools.ai_mind_tool.ai_mind_tool import AIMindTool
from crewai_tools.tools.apify_actors_tool.apify_actors_tool import ApifyActorsTool
from crewai_tools.tools.arxiv_paper_tool.arxiv_paper_tool import ArxivPaperTool
from crewai_tools.tools.boxlite_sandbox_tool import (
BoxLiteExecTool,
BoxLiteFileTool,
BoxLitePythonTool,
)
from crewai_tools.tools.brave_search_tool.brave_image_tool import BraveImageSearchTool
from crewai_tools.tools.brave_search_tool.brave_llm_context_tool import (
BraveLLMContextTool,
Expand Down Expand Up @@ -224,6 +229,9 @@
"ArxivPaperTool",
"BedrockInvokeAgentTool",
"BedrockKBRetrieverTool",
"BoxLiteExecTool",
"BoxLiteFileTool",
"BoxLitePythonTool",
"BraveImageSearchTool",
"BraveLLMContextTool",
"BraveLocalPOIsDescriptionTool",
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
@@ -1,6 +1,11 @@
from crewai_tools.tools.ai_mind_tool.ai_mind_tool import AIMindTool
from crewai_tools.tools.apify_actors_tool.apify_actors_tool import ApifyActorsTool
from crewai_tools.tools.arxiv_paper_tool.arxiv_paper_tool import ArxivPaperTool
from crewai_tools.tools.boxlite_sandbox_tool import (
BoxLiteExecTool,
BoxLiteFileTool,
BoxLitePythonTool,
)
from crewai_tools.tools.brave_search_tool.brave_image_tool import BraveImageSearchTool
from crewai_tools.tools.brave_search_tool.brave_llm_context_tool import (
BraveLLMContextTool,
Expand Down Expand Up @@ -209,6 +214,9 @@
"AIMindTool",
"ApifyActorsTool",
"ArxivPaperTool",
"BoxLiteExecTool",
"BoxLiteFileTool",
"BoxLitePythonTool",
"BraveImageSearchTool",
"BraveLLMContextTool",
"BraveLocalPOIsDescriptionTool",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# BoxLite Sandbox Tools

Run shell commands, execute Python, and manage files inside a [BoxLite](https://boxlite.ai/) micro-VM. BoxLite boots an OCI image inside a hardware-isolated micro-VM **on the local host**, so — unlike the E2B and Daytona sandbox tools — there is no API key, account, or cloud round-trip. It is a self-hosted option for agent-driven code execution with a VM-strength isolation boundary.

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

- **`BoxLiteExecTool`** — run a shell command, returning exit code, stdout, and stderr.
- **`BoxLitePythonTool`** — run a block of Python (`python3`) and return its stdout, stderr, and exit code.
- **`BoxLiteFileTool`** — read / write / append / list / delete / mkdir / info / exists.

## Installation

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

No API key is required. The host must support micro-VMs:

| Platform | Requirement |
| --- | --- |
| macOS 12+ | Apple Silicon (`Hypervisor.framework`) |
| Linux | KVM enabled (`/dev/kvm` accessible) |

## Box lifecycle

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

| Mode | When the box is created | When it is removed |
| --- | --- | --- |
| **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()` |

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 — typical when pairing `BoxLiteFileTool` with `BoxLiteExecTool`.

Configure the box with `image` (default `python:slim`), `cpus`, and `memory_mib`.

## Examples

### One-shot Python execution (ephemeral)

```python
from crewai_tools import BoxLitePythonTool

tool = BoxLitePythonTool()
result = tool.run(code="print(sum(range(10)))")
# {"exit_code": 0, "stdout": "45\n", "stderr": ""}
```

### Multi-step shell session (persistent)

```python
from crewai_tools import BoxLiteExecTool, BoxLiteFileTool

exec_tool = BoxLiteExecTool(persistent=True, image="python:slim")
file_tool = BoxLiteFileTool(persistent=True, image="python:slim")

# Each tool keeps its own persistent box for the life of the process.
file_tool.run(action="write", path="/tmp/data.txt", content="1\n2\n3\n")
exec_tool.run(command="wc -l < /tmp/data.txt")
```

### Give the tools to an agent

```python
from crewai import Agent
from crewai_tools import BoxLiteExecTool, BoxLitePythonTool

analyst = Agent(
role="Data Analyst",
goal="Run code safely in an isolated micro-VM",
backstory="Executes untrusted, model-generated code without touching the host.",
tools=[BoxLitePythonTool(), BoxLiteExecTool()],
)
```

## Notes and differences from E2B/Daytona

- **Local only.** Boxes run on the host; there is no remote/attach-by-id mode, because BoxLite's synchronous API does not expose one with the rich exec (cwd/env/timeout) these tools use.
- **Text results only.** `BoxLitePythonTool` returns stdout/stderr/exit code; there is no Jupyter kernel, so rich results (charts, dataframes) are not returned.
- **Files over shell.** `BoxLiteFileTool` implements filesystem operations via the box's shell (content is transferred as base64), since BoxLite exposes command execution rather than a files API.
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from crewai_tools.tools.boxlite_sandbox_tool.boxlite_base_tool import BoxLiteBaseTool
from crewai_tools.tools.boxlite_sandbox_tool.boxlite_exec_tool import BoxLiteExecTool
from crewai_tools.tools.boxlite_sandbox_tool.boxlite_file_tool import BoxLiteFileTool
from crewai_tools.tools.boxlite_sandbox_tool.boxlite_python_tool import (
BoxLitePythonTool,
)


__all__ = [
"BoxLiteBaseTool",
"BoxLiteExecTool",
"BoxLiteFileTool",
"BoxLitePythonTool",
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
from __future__ import annotations

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

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


logger = logging.getLogger(__name__)


class BoxLiteBaseTool(BaseTool):
"""Shared base for tools that act on a BoxLite micro-VM box.

BoxLite boots an OCI image inside a hardware-isolated micro-VM on the local
host, so — unlike the E2B and Daytona sandbox tools — no API key or cloud
account is required. The host must support micro-VMs: macOS 12+ on Apple
Silicon, or Linux with KVM (``/dev/kvm``).

Lifecycle modes (mirrors the E2B/Daytona sandbox tools):
- ``persistent=False`` (default): create a fresh box per ``_run`` call and
remove it when the call returns. Stateless and safe — nothing leaks if
the agent forgets cleanup.
- ``persistent=True``: lazily create one box on first use, cache it on the
instance, and remove it at process exit. Cheaper across many calls and
lets files or installed packages carry over between them.

Boxes are addressed only through this tool instance; there is no attach to a
box created elsewhere, because BoxLite's synchronous API does not expose
one with the rich exec (cwd/env/timeout) these tools rely on.
"""

model_config = ConfigDict(arbitrary_types_allowed=True)

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

image: str = Field(
default="python:slim",
description=(
"OCI image the micro-VM boots from. Any Docker/OCI reference works "
"(e.g. 'python:slim', 'ubuntu:24.04')."
),
)
cpus: int | None = Field(
default=None,
description="vCPUs for the box. Defaults to the BoxLite runtime default.",
)
memory_mib: int | None = Field(
default=None,
description="Memory in MiB for the box. Defaults to the BoxLite runtime default.",
)
persistent: bool = Field(
default=False,
description=(
"If True, reuse one box across all calls to this tool instance and "
"remove it at process exit. Default False creates and removes a "
"fresh box per call."
),
)

_persistent_box: 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_boxlite(cls) -> Any:
"""Return the ``boxlite`` module.

Imported lazily and cached so that importing this tool (e.g. for spec
generation or ``from crewai_tools import ...``) never requires the
optional ``boxlite`` dependency to be installed.
"""
cached = cls._sdk_cache.get("boxlite")
if cached is not None:
return cached
try:
import boxlite # type: ignore[import-untyped]
except ImportError as exc:
raise ImportError(
"The 'boxlite' package is required for BoxLite sandbox tools. "
"Install it with: uv add 'boxlite[sync]' (or) "
"pip install 'boxlite[sync]'"
) from exc
cls._sdk_cache["boxlite"] = boxlite
return boxlite

def _new_box(self) -> Any:
"""Create and start a fresh box from this tool's configuration."""
boxlite = self._import_boxlite()
box = boxlite.SyncSimpleBox(
image=self.image,
cpus=self.cpus,
memory_mib=self.memory_mib,
auto_remove=True,
)
# SyncSimpleBox exposes its lifecycle only via the context-manager
# protocol; enter it here and exit in _release_box/close so the box can
# outlive a single ``with`` block (required for persistent mode).
box.__enter__()
return box

def _acquire_box(self) -> tuple[Any, bool]:
"""Return ``(box, should_remove_after_use)``."""
if self.persistent:
with self._lock:
if self._persistent_box is None:
self._persistent_box = self._new_box()
if not self._cleanup_registered:
atexit.register(self.close)
self._cleanup_registered = True
return self._persistent_box, False
return self._new_box(), True

def _release_box(self, box: Any, should_remove: bool) -> None:
if not should_remove:
return
try:
box.__exit__(None, None, None)
except Exception:
logger.debug(
"Best-effort box cleanup failed after ephemeral use; "
"the micro-VM may need manual removal.",
exc_info=True,
)

def close(self) -> None:
"""Remove the cached persistent box if one exists."""
with self._lock:
box = self._persistent_box
self._persistent_box = None
if box is None:
return
try:
box.__exit__(None, None, None)
except Exception:
logger.debug(
"Best-effort persistent box cleanup failed at close(); "
"the micro-VM may need manual removal.",
exc_info=True,
)

@staticmethod
def _result_dict(result: Any) -> dict[str, Any]:
"""Normalize a BoxLite ExecResult into a plain JSON-friendly dict."""
return {
"exit_code": result.exit_code,
"stdout": result.stdout,
"stderr": result.stderr,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
from __future__ import annotations

from builtins import type as type_
from typing import Any

from pydantic import BaseModel, Field

from crewai_tools.tools.boxlite_sandbox_tool.boxlite_base_tool import BoxLiteBaseTool


class BoxLiteExecToolSchema(BaseModel):
command: str = Field(..., description="Shell command to execute in the box.")
cwd: str | None = Field(
default=None,
description="Working directory to run the command in. Defaults to the box's configured workdir.",
)
envs: dict[str, str] | None = Field(
default=None,
description="Optional environment variables to set for this command.",
)
timeout: float | None = Field(
default=None,
description="Maximum seconds to wait for the command to finish.",
)


class BoxLiteExecTool(BoxLiteBaseTool):
"""Run a shell command inside a BoxLite micro-VM."""

name: str = "BoxLite Sandbox Exec"
description: str = (
"Execute a shell command inside a BoxLite micro-VM and return the exit "
"code, stdout, and stderr. Use this to run builds, package installs, "
"git operations, or any one-off shell command."
)
args_schema: type_[BaseModel] = BoxLiteExecToolSchema

def _run(
self,
command: str,
cwd: str | None = None,
envs: dict[str, str] | None = None,
timeout: float | None = None,
) -> Any:
box, should_remove = self._acquire_box()
try:
result = box.exec(
"/bin/sh", "-c", command, env=envs, cwd=cwd, timeout=timeout
)
return self._result_dict(result)
finally:
self._release_box(box, should_remove)
Loading