Sandboxed bash interpreter for Python. Native bindings to the bashkit Rust core for fast, in-process execution with a virtual filesystem.
- Sandboxed execution in-process, without containers or subprocess orchestration
- Full bash syntax: variables, pipelines, redirects, loops, functions, and arrays
- 160 built-in commands including
grep,sed,awk,jq,curl, andfind - Persistent interpreter state across calls, including variables, cwd, and VFS contents
- Direct virtual filesystem APIs, constructor mounts, and live host mounts
- Snapshot and restore support on
BashandBashTool - AI integrations for LangChain, PydanticAI, and Deep Agents
pip install bashkit
# Optional integrations
pip install 'bashkit[langchain]'
pip install 'bashkit[pydantic-ai]'
pip install 'bashkit[deepagents]'from bashkit import Bash
bash = Bash()
result = bash.execute_sync("echo 'Hello, World!'")
print(result.stdout) # Hello, World!
bash.execute_sync("export APP_ENV=dev")
print(bash.execute_sync("echo $APP_ENV").stdout) # devimport asyncio
from bashkit import Bash
async def main():
bash = Bash()
result = await bash.execute("echo -e 'banana\\napple\\ncherry' | sort")
print(result.stdout) # apple\nbanana\ncherry
await bash.execute("printf 'data\\n' > /tmp/file.txt")
saved = await bash.execute("cat /tmp/file.txt")
print(saved.stdout) # data
asyncio.run(main())from bashkit import Bash
bash = Bash(
username="agent",
hostname="sandbox",
max_commands=1000,
max_loop_iterations=10000,
max_memory=10 * 1024 * 1024,
timeout_seconds=30,
python=False,
)from bashkit import Bash
bash = Bash()
def on_output(stdout: str, stderr: str) -> None:
if stdout:
print(stdout, end="", flush=True)
if stderr:
print(stderr, end="", flush=True)
result = bash.execute_sync(
"for i in 1 2 3; do echo out-$i; echo err-$i >&2; done",
on_output=on_output,
)on_output is optional and fires during execution with chunked (stdout, stderr)
pairs. Chunks are not line-aligned or exact terminal interleaving, but
concatenating all callback chunks matches the final ExecResult.stdout and
ExecResult.stderr. The handler must be synchronous; async def callbacks and
callbacks that return awaitables are rejected.
from bashkit import Bash
bash = Bash()
bash.mkdir("/data", recursive=True)
bash.write_file("/data/config.json", '{"debug": true}\n')
bash.append_file("/data/config.json", '{"trace": false}\n')
print(bash.read_file("/data/config.json"))
print(bash.exists("/data/config.json"))
print(bash.ls("/data"))
print(bash.glob("/data/*.json"))The same direct filesystem helpers are available on BashTool().
from bashkit import Bash
bash = Bash()
fs = bash.fs()
fs.mkdir("/data", recursive=True)
fs.write_file("/data/blob.bin", b"\x00\x01hello")
fs.copy("/data/blob.bin", "/data/backup.bin")
assert fs.read_file("/data/blob.bin") == b"\x00\x01hello"
assert fs.exists("/data/backup.bin")from bashkit import Bash, FileSystem
source = FileSystem()
source.write_file("/org/repo/README.md", b"hello\n")
capsule = source.to_capsule()
imported = FileSystem.from_capsule(capsule)
bash = Bash()
bash.mount("/workspace", imported)
print(bash.execute_sync("cat /workspace/org/repo/README.md").stdout)to_capsule() / from_capsule() exchange a versioned stable ABI handle so
separate PyO3 extensions do not need to share bashkit's internal Rust object
layout. Native extensions should depend on bashkit with the interop feature
and use bashkit::interop::fs.
from bashkit import Bash
bash = Bash(files={
"/config/static.txt": "ready\n",
"/config/report.json": lambda: '{"ok": true}\n',
})
print(bash.execute_sync("cat /config/static.txt").stdout)
print(bash.execute_sync("cat /config/report.json").stdout)from bashkit import Bash
bash = Bash(mounts=[
{"host_path": "/path/to/data", "vfs_path": "/data"},
{"host_path": "/path/to/workspace", "vfs_path": "/workspace", "writable": True},
])
print(bash.execute_sync("ls /workspace").stdout)from bashkit import Bash, FileSystem
bash = Bash()
workspace = FileSystem.real("/path/to/workspace", writable=True)
bash.mount("/workspace", workspace)
bash.execute_sync("echo 'hello' > /workspace/demo.txt")
bash.unmount("/workspace")curl, wget, and http are gated behind an explicit allowlist. Without a
network= kwarg outbound HTTP is disabled (the secure default). Pass an
explicit allowlist or allow_all=True to opt in:
from bashkit import Bash
# Per-host allowlist - all other URLs are blocked.
bash = Bash(network={"allow": ["https://api.github.com", "https://api.openai.com/v1"]})
# Allow every URL (mirrors NetworkAllowlist::allow_all() in the Rust core).
trusted = Bash(network={"allow_all": True})
# Disable the SSRF guard if you legitimately need to reach a private IP.
local = Bash(network={"allow": ["http://127.0.0.1:8080"], "block_private_ips": False})network= is also accepted by BashTool(...) and persists across reset()
and from_snapshot(...). An explicit network={"allow": []} blocks every
URL but is distinct from omitting network= entirely.
Inject per-host credentials transparently so scripts never see the real
secret. Two modes are supported (mirroring the Rust BashBuilder::credential
and BashBuilder::credential_placeholder APIs):
from bashkit import Bash
bash = Bash(
network={
"allow": ["https://api.github.com", "https://api.openai.com/v1"],
# Direct injection — script has no knowledge of the credential.
"credentials": [
{
"pattern": "https://api.github.com",
"kind": "bearer",
"token": "ghp_xxx",
},
],
# Placeholder mode — script sees an opaque placeholder via env var.
"credential_placeholders": [
{
"env": "OPENAI_API_KEY",
"pattern": "https://api.openai.com",
"kind": "bearer",
"token": "sk-real-key",
},
],
},
)
# Scripts can: curl -s https://api.github.com/repos/foo/bar
# → Authorization: Bearer ghp_xxx is added on the wire.
# Scripts can: curl -s -H "Authorization: Bearer $OPENAI_API_KEY" \
# https://api.openai.com/v1/chat
# → the placeholder is replaced with sk-real-key on the wire.Each credential dict accepts kind: "bearer" (token),
kind: "header" (name, value), or kind: "headers" (a list of
(name, value) pairs). Injected headers overwrite script-provided headers
with the same name to prevent credential spoofing. Phase 2 of #1348 covers
the credential surface; request callbacks and bot-auth ship in follow-ups.
from bashkit import Bash, BashError
bash = Bash()
try:
bash.execute_sync_or_throw("exit 42")
except BashError as err:
print(err.exit_code) # 42
print(err.stderr)
print(str(err))Use execute_or_throw() and execute_sync_or_throw() when you want failures surfaced as exceptions instead of inspecting exit_code manually.
from bashkit import Bash
bash = Bash()
bash.cancel() # abort in-flight execution (no-op if idle)
bash.clear_cancel() # clear the sticky flag so subsequent executions workcancel() sets a sticky flag that causes every future execute() to fail
immediately with "execution cancelled". Call clear_cancel() after the
cancelled execution finishes to restore the instance for reuse — this
preserves all VFS state. Use reset() only when you want to discard VFS
and shell state entirely.
BashTool exposes the same cancel(), clear_cancel(), and reset() methods.
from bashkit import Bash
bash = Bash()
bash.execute_sync("mkdir -p /workspace && cd /workspace")
state = bash.shell_state()
prompt = f"{state.cwd}$ "
print(prompt) # /workspace$
bash.execute_sync("cd /")
print(state.cwd) # still /workspaceShellState is a read-only snapshot for prompt rendering and inspection.
It is a Python-friendly inspection view rather than a full Rust-shell mirror,
and fields like env, variables, and arrays are exposed as immutable
mappings. Use snapshot(exclude_filesystem=True) when you need shell-only
restore bytes, or snapshot(exclude_filesystem=True, exclude_functions=True)
when prompt rendering does not need function restore.
Transient fields like last_exit_code and traps are captured on the snapshot,
but the next top-level execute() / execute_sync() clears them before running
the new command.
BashTool exposes the same shell_state() method.
BashTool wraps Bash and adds tool-contract metadata for agent frameworks:
nameshort_descriptionversiondescription()help()system_prompt()input_schema()output_schema()
from bashkit import BashTool
tool = BashTool()
print(tool.description())
print(tool.input_schema())
result = tool.execute_sync("echo 'Hello from BashTool'")
print(result.stdout)Use custom_builtins={...} on Bash or BashTool when you want
constructor-time Python callback builtins without giving up persistent
shell/VFS state:
from bashkit import Bash
import json
def get_order(ctx):
if not ctx.argv or ctx.argv[0] in {"help", "--help"}:
return "usage: get-order <get|help> [args]\n"
if ctx.argv[0] == "get" and len(ctx.argv) >= 2:
return json.dumps(
{"id": ctx.argv[1], "status": "shipped", "items": ["widget"]}
) + "\n"
return "usage: get-order <get|help> [args]\n"
bash = Bash(custom_builtins={"get-order": get_order})
bash.execute_sync("mkdir -p /scratch && get-order get 42 > /scratch/order.json")
result = bash.execute_sync("cat /scratch/order.json | jq -r '.items[]'")
print(result.stdout) # widgetCallbacks receive a BuiltinContext object with raw argv tokens, optional
pipeline stdin, the current cwd, and visible env. They may return either
the builtin stdout string directly or a BuiltinResult(stdout=..., stderr=..., exit_code=...) for explicit shell-shaped failures. Raise an exception for
unexpected callback failures. Async callbacks support the same return shapes.
BashTool exposes the same custom_builtins constructor kwarg and includes
registered command names in help() output for LLM-facing metadata.
from bashkit import BuiltinResult
def view_image(ctx):
if not ctx.argv:
return BuiltinResult(stderr="view-image: missing path\n", exit_code=1)
return BuiltinResult(stdout="")When you use await bash.execute(...) or await bash_tool.execute(...),
async callbacks are scheduled back onto the caller's active asyncio loop, so
loop-bound primitives like asyncio.Event and framework-owned clients keep
working. execute_sync() still supports async callbacks, but it drives them on
an internal private loop and should not be called from an async endpoint.
Use ScriptedTool to register Python callbacks as bash-callable tools:
from bashkit import ScriptedTool
def get_user(params, stdin=None):
return '{"id": 1, "name": "Alice"}'
tool = ScriptedTool("api")
tool.add_tool(
"get_user",
"Fetch user by ID",
callback=get_user,
schema={"type": "object", "properties": {"id": {"type": "integer"}}},
)
result = tool.execute_sync("get_user --id 1 | jq -r '.name'")
print(result.stdout) # AliceScriptedTool callbacks receive (params_dict, stdin_or_none) and must return
the tool stdout string. Async callbacks are also supported. await tool.execute(...)
runs async callbacks on the caller's active asyncio loop; execute_sync() falls
back to a private loop because there is no caller loop to reuse.
from bashkit import Bash
bash = Bash(username="agent", max_commands=100)
bash.execute_sync(
"export BUILD_ID=42; "
"greet() { echo \"hi $1\"; }; "
"mkdir -p /workspace && cd /workspace && echo ready > state.txt"
)
snapshot = bash.snapshot()
shell_only = bash.snapshot(exclude_filesystem=True)
prompt_only = bash.snapshot(exclude_filesystem=True, exclude_functions=True)
restored = Bash.from_snapshot(snapshot, username="agent", max_commands=100)
assert restored.execute_sync("echo $BUILD_ID").stdout.strip() == "42"
assert restored.execute_sync("greet agent").stdout.strip() == "hi agent"
assert restored.execute_sync("cat /workspace/state.txt").stdout.strip() == "ready"
restored.reset()
restored.restore_snapshot(snapshot)
assert restored.execute_sync("pwd").stdout.strip() == "/workspace"
restored.restore_snapshot(shell_only)BashTool exposes the same snapshot(), restore_snapshot(...), and from_snapshot(...) APIs.
Python callback builtins are host-side config, not serialized shell state, so
pass custom_builtins= again when constructing a restored instance if you
need them after snapshot restore.
from bashkit.langchain import create_bash_tool
tool = create_bash_tool()from bashkit.pydantic_ai import create_bash_tool
tool = create_bash_tool()from bashkit.deepagents import BashkitBackend, BashkitMiddlewareexecute(commands: str) -> ExecResultexecute_sync(commands: str) -> ExecResultexecute_or_throw(commands: str) -> ExecResultexecute_sync_or_throw(commands: str) -> ExecResultcancel()clear_cancel()reset()- constructor kwarg:
custom_builtins={name: callback} snapshot() -> bytesrestore_snapshot(data: bytes)from_snapshot(data: bytes, **kwargs) -> Bash- constructor kwarg:
network={"allow": [...], "block_private_ips": True}ornetwork={"allow_all": True}, optionally withcredentials=[...]andcredential_placeholders=[...] mount(vfs_path: str, fs: FileSystem)unmount(vfs_path: str)- Direct VFS helpers:
read_file,write_file,append_file,mkdir,remove,exists,stat,read_dir,ls,glob,copy,rename,symlink,chmod,read_link
- All execution, cancellation (
cancel(),clear_cancel()), reset, snapshot, restore, mount, and direct VFS helpers fromBash - constructor kwarg:
custom_builtins={name: callback} - Tool metadata:
name,short_description,version description() -> strhelp() -> strsystem_prompt() -> strinput_schema() -> stroutput_schema() -> str
add_tool(name, description, callback, schema=None)execute(script: str) -> ExecResultexecute_sync(script: str) -> ExecResultexecute_or_throw(script: str) -> ExecResultexecute_sync_or_throw(script: str) -> ExecResultenv(key: str, value: str)tool_count() -> int
mkdir(path, recursive=False)write_file(path, content)read_file(path) -> bytesappend_file(path, content)exists(path) -> boolremove(path, recursive=False)stat(path) -> dictread_dir(path) -> listrename(src, dst)copy(src, dst)symlink(target, link)chmod(path, mode)read_link(path) -> strFileSystem.real(host_path, writable=False) -> FileSystemFileSystem.from_capsule(capsule) -> FileSystemFileSystem.to_capsule() -> object
ExecResult.stdoutExecResult.stderrExecResult.exit_codeExecResult.errorExecResult.successExecResult.to_dict()BashError.exit_codeBashError.stderr
- Linux:
x86_64,aarch64(glibc and musl wheels) - macOS:
x86_64,aarch64 - Windows:
x86_64 - Python:
3.9through3.14
Bashkit is built on the bashkit Rust core, which implements a sandboxed bash interpreter and virtual filesystem. The Python package exposes that engine through a native extension, so commands run in-process with persistent state and resource limits, without shelling out to the host system.
Bashkit is part of the Everruns ecosystem. See the bashkit monorepo for the Rust core, the JavaScript package (@everruns/bashkit), and related tooling.
MIT