A Python SDK for sandboxed filesystem operations, built on just-bash, AgentFS, and Pyodide. Provides AI agents with a persistent, isolated environment backed by SQLite.
⚠️ Warning: This project is in beta. While it provides isolation through WebAssembly and a simulated bash environment, it has not been security audited and should not be relied upon as a fully secure sandbox for running untrusted code. Use at your own risk.
- Sandboxed Execution: Run bash commands in an isolated environment
- Python Execution: Run Python via Pyodide (WebAssembly) on the same virtual filesystem
- Persistent Filesystem: All file operations persist across commands in SQLite
- Key-Value Store: Separate KV API for agent state management
- Command History: Track all executed commands with timestamps and results
- Snapshot & Resume: Export/restore complete sandbox state
- Execution Limits: Configurable DOS protection (loop iterations, command counts)
- Async Support: Full async API via
asyncio.to_thread - Context Manager: Clean resource management with
withstatement
pip install localsandbox
# or
uv add localsandboxThe package requires Deno to run the TypeScript shim. Install Deno
(brew install deno) and ensure deno is on your PATH.
from localsandbox import LocalSandbox
# Basic usage with context manager (recommended)
with LocalSandbox() as sandbox:
result = sandbox.bash('echo "Hello, World!"')
print(result.stdout) # Hello, World!
# Without context manager
sandbox = LocalSandbox()
try:
result = sandbox.bash('echo "Hello!"')
print(result.stdout)
finally:
sandbox.destroy()
# Seed initial files (all paths use /data prefix)
with LocalSandbox(files={"/data/app/main.py": 'print("hello")'}) as sandbox:
result = sandbox.execute_python('exec(open("main.py").read())', cwd="/data/app")
print(result.stdout) # hello
# Use file helpers (all paths use /data prefix)
with LocalSandbox() as sandbox:
sandbox.write_file("/data/config.json", '{"key": "value"}')
content = sandbox.read_file("/data/config.json")
exists = sandbox.exists("/data/config.json")
files = sandbox.list_files("/data")
# Key-value store
with LocalSandbox() as sandbox:
sandbox.kv.set("user_id", "12345")
user_id = sandbox.kv.get("user_id")
all_keys = sandbox.kv.keys()More runnable scripts are in examples/.
- Python execution architecture:
docs/design/python-execution.md - Tool bridge design:
docs/design/pyodide-tool-bridge.md
LocalSandbox(
files: dict[str, str | Path | bytes] | None = None,
snapshot: bytes | None = None,
cwd: str = "/data",
preset: ExecutionPreset = ExecutionPreset.NORMAL,
)Parameters:
files: Initial filesystem contents. Supports string content,Pathobjects (read at creation), orbytesfor binary files. All paths should use the/dataprefix.snapshot: Restore from a previously exported snapshot (mutually exclusive withfiles).cwd: Initial working directory (default:/data).preset: Execution limits preset (STRICT,NORMAL, orPERMISSIVE).
sandbox.bash(command: str) -> BashResultExecute a bash command. Returns BashResult with stdout, stderr,
exit_code, and duration_ms.
Raises:
CommandError: Non-zero exit codeFileNotFoundError: File/directory not found (with.pathattribute)PermissionError: Permission denied (with.pathattribute)ExecutionLimitError: Execution limits exceededSubprocessCrashed: Shim subprocess failure
sandbox.execute_python(
code: str,
cwd: str | None = None,
preload_packages: list[str] | None = None,
toolset: PythonToolset | Sequence[Callable[..., object]] | None = None,
) -> PythonResultExecute Python via Pyodide. The sandbox filesystem is mounted at /data in both
bash and Python environments. All paths should use the /data prefix for
consistency across all operations (bash, Python, and file helpers).
If preload_packages is provided, those Pyodide packages are loaded before
execution. No network access is granted unless preloading is requested.
If toolset is provided, the sandbox code can call host-side tools via from host_tools import call and search the declared toolset via host_tools.search().
The simplest form is to pass a list of typed Python callables. LocalSandbox infers the tool name, description, input schema, and output schema from the function signature, type hints, and docstring:
from localsandbox import LocalSandbox
def web_search(query: str) -> dict[str, list[str]]:
"""Search the web for information."""
return {"results": ["result1", "result2"]}
with LocalSandbox() as sandbox:
result = sandbox.execute_python(
"""
from host_tools import call, search
print(search("web"))
response = call("web_search", {"query": "hello"})
print(response["results"])
""",
toolset=[web_search],
)Alternatively you can pass an explicit PythonToolset if you need full control over schemas, names, or timeouts.
execute_python() reuses a warmed Python runner when the tool manifest and preload_packages are unchanged. Files under /data always persist across calls. Python interpreter state may also persist across compatible calls, but sholudn't be depended on. The requested working directory is reapplied for each execution. Create a new LocalSandbox if you need a fresh interpreter.
sandbox.read_file(path: str) -> str
sandbox.write_file(path: str, content: str) -> None
sandbox.list_files(path: str) -> list[str]
sandbox.exists(path: str) -> bool
sandbox.delete_file(path: str) -> Nonesandbox.kv.get(key: str) -> str | None
sandbox.kv.set(key: str, value: str) -> None
sandbox.kv.delete(key: str) -> None
sandbox.kv.keys(prefix: str = "") -> list[str]sandbox.history(limit: int = 100) -> list[HistoryEntry]Get the history of tool calls executed on this sandbox. Returns a list of
HistoryEntry objects with:
id: Unique identifiername: Tool name (e.g., "bash" or "python")started_at: Unix timestamp when command startedcompleted_at: Unix timestamp when command finishedparameters: Dict withcommand/cwd(bash) orcodeLength/cwd(python)result: Dict withexitCode
from localsandbox import LocalSandbox
with LocalSandbox() as sandbox:
sandbox.bash('echo "hello"')
sandbox.bash('ls -la')
history = sandbox.history()
for entry in history:
print(f"Command: {entry.parameters['command']}, Exit: {entry.result['exitCode']}")# Export current state
snapshot = sandbox.export_snapshot()
# Resume from snapshot
new_sandbox = LocalSandbox(snapshot=snapshot)sandbox.destroy() # Clean up resources (called automatically by context manager)All methods have async versions prefixed with a:
import asyncio
from localsandbox import LocalSandbox
async def main():
sandbox = LocalSandbox()
try:
result = await sandbox.abash('echo "async!"')
await sandbox.awrite_file("/data/tmp/test.txt", "content")
content = await sandbox.aread_file("/data/tmp/test.txt")
await sandbox.kv.aset("key", "value")
value = await sandbox.kv.aget("key")
finally:
await sandbox.adestroy()
asyncio.run(main())| Preset | Max Loop Iterations | Max Commands |
|---|---|---|
| STRICT | 100 | 500 |
| NORMAL | 1,000 | 5,000 |
| PERMISSIVE | 10,000 | 50,000 |
from localsandbox import LocalSandbox, ExecutionPreset
# For untrusted input
sandbox = LocalSandbox(preset=ExecutionPreset.STRICT)
# For complex operations
sandbox = LocalSandbox(preset=ExecutionPreset.PERMISSIVE)LocalSandbox uses a TypeScript shim (running on Deno) that bridges Python to:
- just-bash: A bash interpreter/simulator written in TypeScript
- AgentFS: SQLite-based virtual filesystem
- Pyodide: Python interpreter compiled to WebAssembly for sandboxed Python execution
Each bash call spawns a Deno subprocess that runs just-bash against the AgentFS database. Python execution uses a persistent Deno/Pyodide subprocess across compatible calls, communicating via line-delimited JSON over stdio. When a toolset is provided, tool calls are relayed between the sandbox and host handlers through the same protocol.
Both bash and Python share the same virtual filesystem backed by SQLite.
# Install dependencies
uv sync
# Run tests
uv run pytest
# Type checking
uv run pyright
# Lint and format
uv run ruff check --fix && uv run ruff format
# Shim checks
cd shim && deno task checkMIT