# Bashkit — full documentation > Bashkit runs untrusted shell scripts from AI agents without spawning a single OS process. 230 reimplemented commands, substantial POSIX shell language coverage, a virtual filesystem, resource limits, and tool interfaces for agent frameworks — all in-memory, all sandboxed. This file inlines every published guide in navigation order. The canonical Markdown for each guide is also available individually at /docs/.md. # Get started Source: https://bashkit.sh/docs/start.md Bashkit is a single Rust core, a sandboxed bash interpreter with a virtual filesystem, no `fork`/`exec`, and no host access, shipped as a package for every major runtime. Pick the one that matches where your code runs, and follow its quickstart to a first script in a couple of minutes. ## Choose your target | Target | Install | Runs in | Quickstart | |--------|---------|---------|-----------| | **Rust** | `cargo add bashkit` | Any Rust app | [Get started in Rust](start-rust.md) | | **C ABI** | Native shared library | C-compatible native hosts | [C API](c-api.md) | | **Python** | `pip install bashkit` | CPython 3.9+ | [Get started in Python](start-python.md) | | **Node / Bun / Deno** | `npm i @everruns/bashkit` | Node ≥ 18, Bun, Deno | [Get started in Node](start-node.md) | | **Browser (WASM)** | `npm i @everruns/bashkit-wasm` | Browser, edge runtimes | [Get started in the browser](start-browser.md) | | **Pyodide** | `micropip.install("bashkit")` | Pyodide, JupyterLite | [Get started in Pyodide](start-pyodide.md) | | **CLI** | `cargo install bashkit-cli` | Terminal | [CLI](cli.md) | **Which one?** - Embedding in a **Rust, C-compatible, Python, or Node/Bun/Deno** service → the native crate, wheel, or NAPI addon. These share the full feature set. - Running in a **browser or edge/serverless runtime** (Cloudflare Workers, Vercel Edge, Deno Deploy), or anywhere a native addon can't load → the [browser (WASM) package](start-browser.md). It even has a [live terminal](start-browser.md#try-it-live) you can try right on the page. - Running inside **Pyodide or JupyterLite** (Python in the browser) → the [Pyodide wheel](start-pyodide.md). - Running scripts from a **terminal** → the [CLI](cli.md). ## The same core everywhere Whichever target you choose, the shell semantics are identical: the same builtins, the same virtual filesystem, the same sandbox. What differs is the host API surface (async vs sync, available features) and packaging, each quickstart calls out its specifics. Once you have a first script running, [Sandbox configuration & limits](configuration.md) covers the knobs shared across every binding: resource limits, filesystem backends, identity, and the network allowlist. ## Next steps - [LLM tools](llm-tools.md), expose Bashkit as a sandboxed tool for agent frameworks. - [Sandbox configuration & limits](configuration.md), resource limits and sandbox options. - [Security](security.md), sandbox boundaries and what scripts cannot do. --- # Rust Source: https://bashkit.sh/docs/start-rust.md Embed the Bashkit sandbox as a library in your Rust application or agent runtime. Every command is reimplemented in Rust and executes in-process against a virtual filesystem, no `fork`, no `exec`, no host shell. ## Install ```bash cargo add bashkit ``` Opt-in features pull in heavier capabilities only when you need them: ```bash cargo add bashkit --features http_client cargo add bashkit --features git cargo add bashkit --features python cargo add bashkit --features typescript cargo add bashkit --features sqlite cargo add bashkit --features realfs cargo add bashkit-scripted-tool # ScriptedTool / ToolDef / ToolRegistry ``` `http_client` enables `curl`/`wget` and the network allowlist; it uses rustls and needs a crypto backend, `ring` (enabled by default) or `aws-lc-rs`. With `--no-default-features`, name one explicitly: ```bash cargo add bashkit --no-default-features --features http_client,ring cargo add bashkit --no-default-features --features http_client,aws-lc-rs ``` `python` embeds the [Monty](https://github.com/pydantic/monty) interpreter, see the [Python builtin](../crates/bashkit/docs/python.md) guide to run Python inside the shell, and [Get started in Python](start-python.md) to embed Bashkit *in* a Python app. `cpython` runs real CPython 3.14 in a WebAssembly sandbox instead, see the [CPython guide](../crates/bashkit/docs/cpython.md). ## First script ```rust use bashkit::Bash; #[tokio::main] async fn main() -> anyhow::Result<()> { let mut bash = Bash::new(); let result = bash.exec("echo hello world").await?; print!("{}", result.stdout); Ok(()) } ``` ## Persistent state A `Bash` instance keeps its environment and virtual filesystem across calls, so each `exec` sees what the previous one did: ```rust use bashkit::Bash; # #[tokio::main] # async fn main() -> anyhow::Result<()> { let mut bash = Bash::new(); bash.exec("export APP_ENV=dev").await?; bash.exec("printf 'data\n' > /tmp/file.txt").await?; let env = bash.exec("echo $APP_ENV").await?; let file = bash.exec("cat /tmp/file.txt").await?; assert_eq!(env.stdout, "dev\n"); assert_eq!(file.stdout, "data\n"); # Ok(()) # } ``` ## Configure the sandbox Use the builder to set resource limits, the filesystem, identity, and the working directory: ```rust use bashkit::{Bash, ExecutionLimits, InMemoryFs}; use std::sync::Arc; let limits = ExecutionLimits::new() .max_commands(1000) .max_loop_iterations(10000) .max_function_depth(16); let mut bash = Bash::builder() .fs(Arc::new(InMemoryFs::new())) .env("HOME", "/home/agent") .cwd("/home/agent") .username("agent") .hostname("sandbox") .limits(limits) .build(); ``` See [Sandbox configuration & limits](configuration.md) for the full set of options, including the network allowlist. ## Examples Runnable Rust examples in the repo: - [`basic.rs`](https://github.com/everruns/bashkit/blob/main/crates/bashkit/examples/basic.rs), minimal execution - [`resource_limits.rs`](https://github.com/everruns/bashkit/blob/main/crates/bashkit/examples/resource_limits.rs), enforcing limits - [`custom_builtins.rs`](https://github.com/everruns/bashkit/blob/main/crates/bashkit/examples/custom_builtins.rs), adding your own commands - [`agent_tool.rs`](https://github.com/everruns/bashkit/blob/main/crates/bashkit/examples/agent_tool.rs), exposing Bashkit as an LLM tool ## Next steps - [Sandbox configuration & limits](configuration.md), resource limits, filesystem, allowlist. - [Custom builtins](../crates/bashkit/docs/custom_builtins.md), add your own Rust commands to the shell. - [Snapshotting](snapshotting.md), serialize and restore interpreter state. - [Security](security.md), sandbox boundaries and what scripts cannot do. - Full API reference: [docs.rs/bashkit](https://docs.rs/bashkit/latest/bashkit/). --- # Python Source: https://bashkit.sh/docs/start-python.md Embed the Bashkit sandbox in a Python application or agent. The package ships as pre-built binary wheels on PyPI, install and go, no Rust toolchain needed. ## Install ```bash pip install bashkit ``` ## First script ```python from bashkit import Bash bash = Bash() result = bash.execute_sync("echo 'Hello, World!'") print(result.stdout) ``` ## Persistent state A `Bash` instance keeps its environment and virtual filesystem across calls: ```python from bashkit import Bash bash = Bash() bash.execute_sync("export APP_ENV=dev") print(bash.execute_sync("echo $APP_ENV").stdout) # dev ``` ## Sync vs async `execute_sync()` runs scripts that complete without suspending, plain bash and `jq`. If you register an async custom builtin (e.g. one that issues an HTTP request), use the awaitable `execute()` instead: ```python result = await bash.execute("echo hi | my_async_tool") ``` ## Embedded Python and TypeScript Native wheels can run Python and TypeScript *inside* the shell. Both runtimes are disabled by default; opt in with `python=True` and `typescript=True`. Python also requires a host-supplied environment gate: ```python from bashkit import BashTool bash = BashTool( python=True, typescript=True, env={"BASHKIT_ALLOW_INPROCESS_PYTHON": "1"}, ) assert bash.execute_sync("python -c 'print(42)'").stdout == "42\n" assert bash.execute_sync("ts -c '42'").stdout == "42\n" ``` Scripts cannot grant Python permission by exporting the gate variable. Runtime flags survive `reset()`; supply them again when constructing from a snapshot. LangChain's `BashkitTool` and deepagents' middleware/backend accept the same flags. This differs from embedding Bashkit in your Python application; see the [Python builtin](../crates/bashkit/docs/python.md) and [TypeScript builtin](../crates/bashkit/docs/typescript.md) guides. ## Examples Runnable Python examples in the repo: - [`bash_basics.py`](https://github.com/everruns/bashkit/blob/main/crates/bashkit-python/examples/bash_basics.py), first scripts and persistent state - [`data_pipeline.py`](https://github.com/everruns/bashkit/blob/main/crates/bashkit-python/examples/data_pipeline.py), pipes and data processing - [`llm_tool.py`](https://github.com/everruns/bashkit/blob/main/crates/bashkit-python/examples/llm_tool.py), exposing Bashkit as an LLM tool - Agent integrations: [deepagents](https://github.com/everruns/bashkit/blob/main/examples/deepagent_coding_agent.py), [Pydantic AI](https://github.com/everruns/bashkit/blob/main/examples/pydantic_ai_bash_agent.py) ## Next steps - [Sandbox configuration & limits](configuration.md), resource limits and sandbox options. - [LLM tools](llm-tools.md), expose Bashkit as a sandboxed tool for agent frameworks. - [Security](security.md), sandbox boundaries and what scripts cannot do. --- # C API Source: https://bashkit.sh/docs/c-api.md Bashkit's experimental C ABI embeds the sandbox in native applications and provides a foundation for external language bindings. It is synchronous, binary-safe, and based on opaque handles. ## Install Prebuilt archives are attached to each [GitHub Release](https://github.com/everruns/bashkit/releases): | Target | Archive | |---|---| | macOS Apple Silicon | `bashkit-capi-aarch64-apple-darwin.tar.gz` | | macOS Intel | `bashkit-capi-x86_64-apple-darwin.tar.gz` | | Linux x86-64 | `bashkit-capi-x86_64-unknown-linux-gnu.tar.gz` | | Linux ARM64 | `bashkit-capi-aarch64-unknown-linux-gnu.tar.gz` | | Windows x86-64 | `bashkit-capi-x86_64-pc-windows-msvc.zip` | Download the adjacent `.sha256` file and verify it before extracting. Each archive contains the public header, both examples, notices, and the matching native library. Windows archives also contain the `bashkit.lib` import library. No Rust toolchain is required when using a release archive. The complete platform-specific compile and deployment commands are in the [`bashkit-capi` README](../crates/bashkit-capi/README.md). ## Build from source ```sh ./scripts/build-c-api.sh --release ``` The shared library is `libbashkit.so` on Linux, `libbashkit.dylib` on macOS, and `bashkit.dll` on Windows. It contains the complete Bashkit runtime; no second Bashkit library is required. Include [`bashkit.h`](../crates/bashkit-capi/include/bashkit.h) in the host application. The Cargo adapter target remains internally named `bashkit_capi` to avoid colliding with Bashkit's Rust and Python workspace artifacts. Source builds require the repository's pinned Rust toolchain and a C compiler. The helper supports macOS and Linux and writes the native consumer artifact to `target/c-api/release/`. ## Link and deploy On Linux and macOS, compile with the extracted `include/` and `lib/` directories: ```sh cc -std=c11 -I "$BASHKIT_SDK/include" app.c \ -L "$BASHKIT_SDK/lib" -lbashkit -o app ``` At runtime the operating-system loader must be able to find `libbashkit.so` or `libbashkit.dylib`. Prefer deploying it in an application-local `lib/` directory and embedding `$ORIGIN/lib` on Linux or `@loader_path/lib` on macOS, as shown in the packaged README. On Windows, link `bashkit.lib` and put `bashkit.dll` beside the executable or on `PATH`. Applications and libraries must use the same architecture. The current Linux archives target GNU libc; a dynamic Bashkit library is self-contained with respect to Bashkit and Rust dependencies but still uses normal platform system libraries. ### CMake Set `BASHKIT_ROOT` to the extracted archive directory: ```cmake add_library(bashkit SHARED IMPORTED) target_include_directories(my_app PRIVATE "${BASHKIT_ROOT}/include") if(WIN32) set_target_properties(bashkit PROPERTIES IMPORTED_LOCATION "${BASHKIT_ROOT}/lib/bashkit.dll" IMPORTED_IMPLIB "${BASHKIT_ROOT}/lib/bashkit.lib") elseif(APPLE) set_target_properties(bashkit PROPERTIES IMPORTED_LOCATION "${BASHKIT_ROOT}/lib/libbashkit.dylib") else() set_target_properties(bashkit PROPERTIES IMPORTED_LOCATION "${BASHKIT_ROOT}/lib/libbashkit.so") endif() target_link_libraries(my_app PRIVATE bashkit) ``` The application must still deploy the runtime library where the platform loader can locate it. ## Run the examples The repository includes two real C programs: ```sh ./scripts/run-c-api-examples.sh ``` [`hello.c`](../crates/bashkit-capi/examples/hello.c) demonstrates instance and result lifetimes by printing the virtual user and current UTC time. [`files.c`](../crates/bashkit-capi/examples/files.c) demonstrates JSON configuration and binary-safe VFS exchange. ## Surface The v1 ABI provides: - library ABI, package version, and compiled-capability queries; - default or JSON-configured Bashkit construction; - synchronous script execution; - exit code, exact stdout bytes, UTF-8 stderr, truncation flags, and optional final environment JSON; - virtual filesystem read, write, directory creation, and removal; - explicit result, buffer, error, and instance destruction. All strings enter as a pointer plus length. Scripts, paths, configuration, and stderr are UTF-8; file contents and exact stdout may contain arbitrary bytes. A null pointer with zero length represents an empty byte sequence. Construction configuration is capped at `BASHKIT_MAX_CONFIG_BYTES` (10 MB). The versioned JSON accepts the closed profile names `hardened`, `standard`, and `interactive`; individual `limits` fields override the selected baseline. ## Errors and shell status An ABI status describes whether Bashkit produced a result. A script ending in `exit 7` is a successful ABI operation whose `BashkitResult` has exit code 7. Invalid arguments, invalid UTF-8, invalid configuration, interpreter failures, and VFS failures return an owned `BashkitError`. Error messages are capped at 1 KiB. Free them with `bashkit_error_free()`. ## Ownership and concurrency The library allocates every opaque object. Release each object with its matching function; do not use the host's `free()` or retain borrowed byte views after the owner is released. Calls against one Bashkit instance are serialized. Independent instances may run concurrently. The caller owns handle lifetime synchronization and must not free a handle while another thread is using it. ## Streaming output `bashkit_execute_streaming(bash, script, capture_limit, callback, context, &result, &error)` drives async engine output through synchronous byte callbacks on the calling thread. The callback receives stream 1 (stdout) or 2 (stderr), and a byte view borrowed only until the callback returns. Return zero to accept the chunk; a nonzero return aborts execution with an error. A slow callback backpressures the engine. Do not block indefinitely inside a callback: the engine cannot preempt native callback code. `capture_limit` bounds retained bytes per stream; zero disables capture. The callback still receives all delivered bytes when capture truncates. A null callback discards delivery. Free the final result normally. See the runnable [streaming C example](../crates/bashkit-capi/examples/streaming.c). Runtime host functions configured on the handle also work with streaming execution, including replies completed from another thread. Callbacks must not call blocking APIs on any handle; same-thread reentry is rejected rather than waiting on a mutex or nesting runtimes. Cross-thread calls that wait for a callback to return must not be joined from that callback. Lock-free `bashkit_cancel()` is permitted. Do not free the handle or unload the library while execution or a callback is active. Independent handles may run concurrently. Cancellation cannot roll back host side effects already performed by a callback. ABI v1 does not expose custom-command registration, asynchronous shell execution, transport hooks, snapshots or external filesystem providers. These need their own lifetime and unload contracts. See [Streaming execution](streaming.md) for the runtime support matrix and remaining whole-buffer boundaries. ## Compatibility Opaque object layouts are private. Existing functions, numeric status values, ownership rules, and v1 configuration meanings will not change incompatibly. New functions and status values may be added. An incompatible contract requires a new ABI major rather than changing v1 in place. When loading Bashkit dynamically, call `bashkit_abi_version()` and require `BASHKIT_ABI_VERSION_1` before using the rest of the API. The package version returned by `bashkit_version()` is informational and is independent of the ABI major. ## Troubleshooting - Linux `libbashkit.so: cannot open shared object file`: set an application rpath or point `LD_LIBRARY_PATH` at the library directory. - macOS `Library not loaded: @rpath/libbashkit.dylib`: add an application rpath or point `DYLD_LIBRARY_PATH` at the library directory. - Windows error 126: place `bashkit.dll` beside the executable and ensure the executable and DLL are both x86-64. - Undefined `bashkit_*` link symbols: place `-lbashkit` after object inputs on Unix and use a header and library from the same release. ## See also - [Get started](start.md), choose another Bashkit package or runtime. - [Sandbox configuration and limits](configuration.md), common sandbox controls. - [Security](security.md), trust boundaries and resource protections. ## Embedded runtime host functions Opt into Python and/or TypeScript through config schema v1. Use `bashkit_create_with_runtime_handler_v1` when supplying `external_functions` (Python) or `typescript_external_functions`. The callback receives one owned `BashkitRuntimeCall` with a JSON request containing `language`, `name`, `args`, and Python `kwargs`. Respond with `bashkit_runtime_call_respond_json`, then call `bashkit_runtime_call_free`. A response is a JSON value, such as `42` or an object. The dispatcher runs on the execution caller's thread. For asynchronous I/O, transfer the call to a worker and return immediately; the worker responds and frees it. Poll `bashkit_runtime_call_cancelled` to stop I/O after a timeout or cancellation. Late responses return `BASHKIT_CANCELLED`. An unanswered call fails the script when freed. Use `bashkit_runtime_call_fail` for a generic script error; log sensitive host diagnostics separately. JSON values support null, booleans, finite numbers, signed 64-bit integers, strings, arrays and string-key objects, with depth <=64 and <=10 MB of JSON. Numbers outside the signed 64-bit range are rejected, including JSON integer literals that a parser would otherwise round into floating-point values. Bytes, functions and other language objects fail explicitly. Blocking APIs invoked from the dispatcher return `BASHKIT_BUSY`. Callback-enabled handles also reject concurrent blocking operations. Queue other handle operations after the callback returns; cancellation and runtime-call APIs remain available. Never free a handle during execution, race a call's destructor, unwind through C, or unload the shared library while any owned object remains. Keep handler code and user data alive through handle lifetime and all executions. Host callbacks must enforce application authorization and observe cancellation themselves. See the runnable [runtime callback example](../crates/bashkit-capi/examples/runtime_functions.c) and the public header for complete ownership rules. --- # Node, Bun & Deno Source: https://bashkit.sh/docs/start-node.md Embed the Bashkit sandbox in a server-side JavaScript or TypeScript runtime. `@everruns/bashkit` is a NAPI native addon, the fastest JS binding, for Node (≥ 18), Bun, and Deno. For browsers and edge runtimes, use [Get started in the browser](start-browser.md) instead. ## Install ```bash npm i @everruns/bashkit ``` ## First script ```typescript import { Bash } from "@everruns/bashkit"; const bash = new Bash(); const result = bash.executeSync('echo "Hello, World!"'); console.log(result.stdout); ``` ## Python and TypeScript host functions Enable either embedded runtime and register named host functions. The same options work with `Bash`, `BashTool`, and the LangChain, OpenAI, Anthropic and Vercel AI adapters. ```typescript const bash = new Bash({ python: true, externalFunctions: ["lookup"], externalHandler: async (name, args, kwargs, { signal }) => { const response = await fetch(`https://api.example.com/items/${encodeURIComponent(String(args[0]))}`, { signal }); return response.json(); }, typescript: true, typescriptExternalFunctions: ["double"], typescriptExternalHandler: (_name, args) => Number(args[0]) * 2, }); await bash.execute(`python -c 'print(lookup(21))'`); console.log((await bash.execute(`ts -c 'await double(21)'`)).stdout); // 42 ``` Use `execute()` for scripts that invoke JavaScript handlers. `executeSync()` returns a script error for such calls because it blocks the JavaScript event loop. Handlers may return a value or a promise. Python receives positional arguments and keyword arguments separately; TypeScript receives positional arguments. Values use the JSON subset: null, booleans, finite numbers, strings, arrays, and objects with string keys, with nesting limited to 64. Integral values must fit JavaScript's safe integer range. Python tuples become arrays; TypeScript `undefined` and an omitted host return become null. Bytes, functions, arbitrary class instances and cyclic values produce script errors. Each execution captures the caller's `AsyncLocalStorage` context before queuing. Its handler signal aborts on cancellation, timeout or completion; pass it to host I/O. A callback cannot reenter its own interpreter or live filesystem. Use another instance for nested execution. Independent asynchronous requests can share an instance and queue safely. Synchronous access during asynchronous execution fails promptly. Independent workers may own instances, including workers stopped with a pending host function. Callbacks survive reset; supply the options again when restoring a snapshot, since host code and request context are never serialized. Host functions run trusted application code. Enforce service authorization and I/O limits there; registering a function explicitly grants scripts that capability. ## Persistent state A `Bash` instance keeps its environment and virtual filesystem across calls: ```typescript const bash = new Bash(); bash.executeSync("X=42"); console.log(bash.executeSync("echo $X").stdout); // 42 ``` ## Sandbox options Pass resource limits, identity, and seed files to the constructor: ```typescript const bash = new Bash({ cwd: "/home/agent", env: { HOME: "/home/agent" }, maxCommands: 1000, maxLoopIterations: 10000, maxMemory: 64 * 1024 * 1024, }); ``` See [Sandbox configuration & limits](configuration.md) for the full set. To run against storage you own rather than the in-memory filesystem, pass an `fs` object, see [Host-backed filesystem](filesystem.md#host-backed-filesystem-js). ## Examples Runnable Node examples in the repo: - [`bash_basics.mjs`](https://github.com/everruns/bashkit/blob/main/examples/bash_basics.mjs), first scripts and persistent state - [`data_pipeline.mjs`](https://github.com/everruns/bashkit/blob/main/examples/data_pipeline.mjs), pipes and data processing - [`custom_builtins.mjs`](https://github.com/everruns/bashkit/blob/main/examples/custom_builtins.mjs), registering JS callbacks as bash commands - [`llm_tool.mjs`](https://github.com/everruns/bashkit/blob/main/examples/llm_tool.mjs), exposing Bashkit as an LLM tool - Agent integrations: [LangChain](https://github.com/everruns/bashkit/blob/main/examples/langchain_agent.mjs), [Vercel AI](https://github.com/everruns/bashkit/blob/main/examples/vercel_ai_tool.mjs), [OpenAI](https://github.com/everruns/bashkit/blob/main/examples/openai_tool.mjs) ## Next steps - [Custom builtins (JS)](custom_builtins_js.md), add your own JavaScript-backed commands. - [Sandbox configuration & limits](configuration.md), resource limits and sandbox options. - [LLM tools](llm-tools.md), expose Bashkit as a sandboxed tool for agent frameworks. --- # Browser (WASM) Source: https://bashkit.sh/docs/start-browser.md Run the Bashkit sandbox in the browser, at the edge, or in any JavaScript runtime that can't load a native addon. `@everruns/bashkit-wasm` is a slim, **single-threaded** `wasm-bindgen` module: unlike a WASI-threads build it needs **no `SharedArrayBuffer` and no cross-origin isolation** (`COOP`/`COEP`) headers, so it drops into any web app, including embedded and third-party iframe contexts where those headers can't be set, and into edge/serverless runtimes (Cloudflare Workers, Vercel Edge, Deno Deploy) that can't use threads. For server-side Node, Bun, or Deno where a native addon loads, prefer the faster [`@everruns/bashkit`](start-node.md) NAPI package instead. ## Install ```bash npm install @everruns/bashkit-wasm ``` ## First script Load the `.wasm` once with `initBashkit()` before constructing `Bash`: ```js import { initBashkit, Bash } from "@everruns/bashkit-wasm"; await initBashkit(); const bash = new Bash(); const result = bash.executeSync('echo "Hello, browser!" | tr a-z A-Z'); console.log(result.stdout); // HELLO, BROWSER! ``` ## Python and TypeScript host functions Use the optional runtime bundle when scripts need embedded Python or TypeScript: ```js import { initBashkit, Bash } from "@everruns/bashkit-wasm/runtimes"; await initBashkit(); const bash = new Bash({ python: true, externalFunctions: ["lookup"], externalHandler: async (_name, args, kwargs, { signal }) => { const response = await fetch(`/api/items/${encodeURIComponent(String(args[0]))}`, { signal }); return response.json(); }, typescript: true, typescriptExternalFunctions: ["double"], typescriptExternalHandler: (_name, args) => Number(args[0]) * 2, }); await bash.execute(`python -c 'print(lookup(21))'`); console.log((await bash.execute(`ts -c 'await double(21)'`)).stdout); // 42 ``` The default import keeps the slim binary. Both bundles use one event loop and work in module workers without SharedArrayBuffer or COOP/COEP. Runtimes remain disabled until enabled in constructor options. Handlers accept the same JSON subset and signatures as the [native JS SDK](start-node.md#python-and-typescript-host-functions). Use async `execute` for host calls. Its signal aborts on timeout, cancellation or completion; pass it to host I/O. An optional `execute(commands, { signal, onOutput })` controls cancellation and streaming. Cancellation is sticky until `clearCancel()`. One execution may run per instance; concurrent or recursive execution rejects with a busy error. Reset preserves handlers; persisted state contains no host code. Terminal supports pure runtimes but rejects runtime callback names; use Bash for host functions. Host code owns service authorization and I/O limits. Runtime host callbacks also work with live commands and the `stdout`/`stderr` byte sinks, explicit `capture` limits, and `AbortSignal` options described in [Streaming I/O](streaming.md). Text `onOutput` observers remain a separate execution mode; use byte sinks when combining output delivery with live execution options. ## Try it live This is the exact package above, running in your browser, no server, nothing you type leaves this page. Launch it and run some bash:
For a full-screen terminal with `vi` and `less`, open the [playground](https://bashkit.sh/playground). It uses the package's `Terminal` class with xterm.js; see [In-process terminal](terminal.md#in-the-browser). ## No bundler Straight from a CDN, in a plain ` ``` ## Async custom builtins Register JS callbacks as bash commands. Async callbacks (e.g. issuing a `fetch`) are awaited by the async `execute()` API: ```js const bash = new Bash({ customBuiltins: { graphql: async (ctx) => { const res = await fetch("/graphql", { method: "POST", headers: { "content-type": "application/json" }, body: ctx.stdin ?? "{}", }); return await res.text(); }, }, }); const out = await bash.execute('echo "{ me { id } }" | graphql | jq .data'); console.log(out.stdout); ``` ## Caveats - It's a `wasm-bindgen` module: it runs in any **JavaScript** host but **not** a non-JS/WASI wasm runtime (`wasmtime`, `wasmer`). - Reduced feature set relative to the native bindings. Prefer [`@everruns/bashkit`](start-node.md) when a native addon can load. A full interactive terminal built on this package lives in [`examples/browser`](https://github.com/everruns/bashkit/tree/main/examples/browser) (single `index.html` on Vite, no build step, no special headers). ## Next steps - [Get started in Pyodide](start-pyodide.md), for Python running in the browser. - [Sandbox configuration & limits](configuration.md), resource limits and sandbox options. - [Security](security.md), sandbox boundaries and what scripts cannot do. --- # Pyodide & JupyterLite Source: https://bashkit.sh/docs/start-pyodide.md For **Python running in the browser** (Pyodide, JupyterLite), Bashkit ships an Emscripten (`wasm32-unknown-emscripten`) wheel, a reduced-feature variant of the native PyPI package that needs no native toolchain. ## Install ```python import micropip await micropip.install("bashkit") # pulls the Pyodide-ABI wheel ``` If your Pyodide host doesn't resolve the ABI wheel automatically, pass the wheel URL to `micropip.install(...)`. ## First script ```python from bashkit import Bash bash = Bash(python=True) print(bash.execute_sync("echo hello && echo 1 | jq .").stdout) ``` ## Feature surface The Pyodide wheel is the in-VFS shell plus embedded `jq` and Monty `python`, driven through blocking `execute_sync()`. **Present on both native and wasm:** `Bash` / `BashTool` / `ScriptedTool`, `execute_sync()`, Monty `python=True`, `jq`, and sync/async custom-builtin callbacks. **Absent on wasm:** the async `execute()` / `execute_or_throw()` methods, `FileSystem.real()`, and capsule `to/from_capsule`. Gated-off configuration kwargs, `network=`, `sqlite=True`, `mounts=`, `external_handler=`, **fail loudly** with `RuntimeError` at construction rather than silently no-op, so you learn immediately that the WASM build can't do it. ## Next steps - [Get started in Python](start-python.md), the full-featured native PyPI wheel. - [Get started in the browser](start-browser.md), the JavaScript WASM package. - [Python builtin](../crates/bashkit/docs/python.md), the embedded Monty runtime in depth. --- # CLI Source: https://bashkit.sh/docs/cli.md Command-line interface for running bash scripts in a sandboxed virtual filesystem. One binary, three modes. ## Modes | Invocation | Mode | |------------|------| | `bashkit -c '…'` | Execute command string, print stdout/stderr, exit | | `bashkit script.sh` | Execute script file | | `bashkit` | Interactive shell (REPL) | Mode is detected from arguments, `-c` wins, then positional script, otherwise REPL. ## Script arguments and stdin Trailing arguments become positional parameters, following bash: after `-c '…'` the first one is `$0` and the rest are `$1`…; for a script file `$0` is the script path and every trailing argument is positional. ```bash bashkit -c 'echo "$0 got $# args: $1"' myname alpha beta # myname got 2 args: alpha bashkit deploy.sh staging --dry-run ``` Piped stdin is forwarded to the script, so filters work as usual: ```bash echo 'one line' | bashkit -c 'grep -c line' ``` One-shot mode reads stdin to EOF *before* execution (capped at 10 MiB), rather than lazily when a command asks for it, and only when stdin is not a terminal. The selected execution timeout bounds this read; pass `--no-stdin` to skip forwarding an inherited pipe. Output streams as it is produced, so a long script is observable while it runs and a run stopped by a resource limit keeps what it already printed. ## Install From crates.io: ```bash cargo install bashkit-cli ``` Prebuilt binary via [`cargo-binstall`](https://github.com/cargo-bins/cargo-binstall): ```bash cargo binstall bashkit-cli ``` ### Build features | Feature | Default | Effect | |---------|---------|--------| | `interactive` | on | Interactive REPL (rustyline, signal-hook, terminal_size) | | `python` | on | `python`/`python3` builtin via Monty | | `cpython` | off | `python`/`python3` as real CPython 3.14 in a WebAssembly sandbox (replaces Monty when both are on) | | `cpython-native` | off | `cpython` compiled to native code at build time: ~4x faster calls, needs executable memory | | `sqlite` | on | `sqlite`/`sqlite3` builtin via Turso (BETA upstream) | | `realfs` | off | `--mount-ro` / `--mount-rw` host filesystem mounts | Build without interactive (library-only deps): ```bash cargo build -p bashkit-cli --no-default-features ``` ## Defaults Builtins enabled out of the box: - **Git** (`git`), local VFS operations (init, add, commit, log, …) - **Python** (`python`, `python3`), embedded via [Monty](https://github.com/pydantic/monty) (requires `python` feature) - **SQLite** (`sqlite`, `sqlite3`), embedded via [Turso](https://github.com/tursodatabase/turso) (requires `sqlite` feature). The CLI auto-injects `BASHKIT_ALLOW_INPROCESS_SQLITE=1` so the runtime opt-in is satisfied transparently. Disabled by default (security): - **HTTP** (`curl`, `wget`), network access stays blocked unless explicitly enabled Disable per-run: | Flag | Effect | |------|--------| | `--http-allow-all` | Enable curl/wget with unrestricted outbound access (trusted scripts only) | | `--no-http` | Force-disable curl/wget builtins | | `--no-git` | Disable git builtin | | `--no-python` | Disable python/python3 builtins | | `--no-sqlite` | Disable sqlite/sqlite3 builtins | | `--no-stdin` | Do not forward the host's stdin to the script | ## Execution limits Command and script modes use the typed `Standard` profile and start from sandboxed default execution limits: 10,000 commands, 10,000 iterations per loop, 1,000,000 total loop iterations, and a 30 second execution timeout. Interactive mode uses the typed `Interactive` profile, so counting limits and the execution timeout are effectively unlimited while secure memory, managed-VFS, network, parser, and embedded-runtime defaults remain on. Override with: | Flag | Meaning | |------|---------| | `--profile hardened\|standard\|interactive` | Select the baseline explicitly (per-field flags below still override it) | | `--max-commands N` | Max commands per run | | `--max-loop-iterations N` | Max iterations in a single loop | | `--max-total-loop-iterations N` | Max iterations across all loops | | `--timeout SECONDS` | Wall-clock execution timeout | ## Host filesystem mounts (`realfs` feature) By default the VFS is in-memory, scripts cannot reach the host. With `realfs`: | Flag | Effect | |------|--------| | `--mount-ro HOST[:VFS]` | Overlay host dir as read-only | | `--mount-rw HOST[:VFS]` | Overlay host dir as read-write | Omit `:VFS` to overlay at VFS root. Both flags repeat. ```bash bashkit --mount-ro /path/to/project -c 'ls /' bashkit --mount-ro /data:/mnt/data -c 'wc -l /mnt/data/*.csv' bashkit --mount-rw /tmp/out:/mnt/out script.sh ``` **Warning.** `--mount-rw` breaks the sandbox boundary, scripts can modify host files. Prefer `--mount-ro` unless writes are required. ## Interactive shell Run `bashkit` with no arguments. The REPL uses `rustyline` for line editing and reuses the same sandbox as `-c`. Lightweight deps, no SQLite, no crossterm. ### Features - Emacs / vi line editing, in-memory history (1 000 entries) - Multiline input, unterminated quotes, `if`/`for`/`while`/`case`/functions reprompt with PS2 until closed - Ctrl-C cancels the running command (propagates via the cancellation token); at an empty prompt it clears the line - Ctrl-D exits the shell - `exit [N]` exits via an `on_exit` hook (works from pipelines and conditionals: `echo bye; exit 1`) - Streaming output, stdout/stderr flushed as produced - TTY detection: `[ -t 0 ]`, `[ -t 1 ]`, `[ -t 2 ]` all return true - Tab completion, builtins, aliases, `$VAR`, VFS paths (directories get trailing `/`) - Fish-style history hints inline (dim gray); accept with right arrow - `COLUMNS`, `LINES` exported from the real terminal size; `SHLVL` incremented from parent ### Prompt (PS1 / PS2) Default `PS1`: `\u@bashkit:\w\$ ` (e.g. `user@bashkit:~$ `). Override with `export PS1='…'`. `PS2` (continuation) defaults to `> `. Supported escapes: | Escape | Meaning | |--------|---------| | `\u` | Username (`$USER`) | | `\h` | Short hostname (up to first `.`) | | `\H` | Full hostname | | `\w` | Working directory, `~` for `$HOME` | | `\W` | Basename of cwd | | `\$` | `$` for non-root, `#` if `EUID=0` | | `\n` `\r` `\a` `\e` | Newline, CR, bell, ESC | | `\[` `\]` | Non-printing sequence markers (ANSI codes) | | `\\` | Literal backslash | ### Startup file Sources `~/.bashkitrc` from the VFS on startup if present. Put it on the host and expose it via `--mount-rw /path:/home/user` (or `--mount-ro` for a read-only rc). Typical contents: aliases, `PS1`, environment. ### Not implemented (by design) - Job control (`bg`/`fg`/`jobs`), no real processes - History expansion (`!!`, `!N`), complexity vs. value - Persistent history file, would leak across sessions, breaks isolation - `exec`, excluded for security ## Examples Text processing: ```bash bashkit -c 'echo "hello world" | tr a-z A-Z' # HELLO WORLD ``` Python (default): ```bash bashkit -c 'python3 -c "print(2 + 2)"' # 4 ``` Monty is on crates.io since 0.0.19, so `python` is a default CLI feature and `cargo install bashkit-cli` ships it. Releases published before that stripped the feature at publish time; if you are on one of those, install from main: ```bash cargo install --git https://github.com/everruns/bashkit --package bashkit-cli --features python --force ``` Git on the VFS: ```bash bashkit -c ' git init /repo cd /repo echo "# readme" > README.md git add README.md git commit -m "init" git log --oneline ' ``` SQLite (default): ```bash bashkit -c "sqlite :memory: 'SELECT 1 + 2'" # 3 bashkit -c "sqlite -header /tmp/notes.sqlite ' CREATE TABLE IF NOT EXISTS notes(id INTEGER PRIMARY KEY, body TEXT); INSERT INTO notes(body) VALUES (\"hello\"); SELECT * FROM notes; '" ``` Disable a builtin: ```bash bashkit --no-python -c 'python --version' # python: command not found bashkit --no-sqlite -c "sqlite :memory: 'SELECT 1'" # sqlite: command not found ``` Run a script file: ```bash bashkit script.sh arg1 arg2 ``` Interactive shell: ```bash bashkit user@bashkit:~$ echo hi hi user@bashkit:~$ exit ``` Mount host workspace read-only and inspect: ```bash bashkit --mount-ro "$PWD:/mnt/repo" -c 'wc -l /mnt/repo/**/*.rs' ``` Tighten limits for an untrusted script: ```bash bashkit --max-commands 1000 --timeout 5 untrusted.sh ``` ## Error handling Stack backtraces are suppressed. Panics emit a single sanitized line (`bashkit: internal error: …`), no paths, line numbers, or dependency versions. ## See also - [`docs/security.md`](security.md), threat model and mitigations - [`README.md`](../README.md), library usage and features --- # LLM tools Source: https://bashkit.sh/docs/llm-tools.md `BashTool` wraps the Bashkit sandbox as a ready-made tool for agent frameworks. It exposes everything a model needs to call a shell safely: discovery metadata (name, description, input schema), a system prompt describing the sandbox, streaming output, and execution that runs against the same in-process virtual filesystem and resource limits as the rest of Bashkit. Use this when you want an agent or LLM app to run shell commands without giving it a real host shell. ## Rust tool contract ```rust use bashkit::{BashTool, Tool}; use futures_util::StreamExt; # #[tokio::main] # async fn main() -> anyhow::Result<()> { let tool = BashTool::builder() .username("agent") .hostname("sandbox") .build(); println!("{}", tool.description()); println!("{}", tool.system_prompt()); let execution = tool.execution(serde_json::json!({ "commands": "printf 'hello\nworld\n'" }))?; let mut stream = execution.output_stream().expect("stream available"); let handle = tokio::spawn(async move { execution.execute().await }); while let Some(chunk) = stream.next().await { println!("{}: {}", chunk.kind, chunk.data); } let output = handle.await??; assert_eq!(output.result["stdout"], "hello\nworld\n"); # Ok(()) # } ``` The `Tool` trait gives you the pieces a framework needs: `description()` and `system_prompt()` for prompting, an input schema for tool-call validation, and streaming `chunk.kind` / `chunk.data` events while the command runs. ## Python ```python from bashkit import BashTool tool = BashTool() print(tool.input_schema()) print(tool.description()) print(tool.system_prompt()) result = tool.execute_sync("echo 'Hello!'") print(result.stdout) ``` The Python package ships optional extras with adapters for popular frameworks: ```bash pip install 'bashkit[langchain]' pip install 'bashkit[pydantic-ai]' pip install 'bashkit[deepagents]' ``` ## TypeScript / JavaScript ```typescript import { BashTool } from "@everruns/bashkit"; const tool = new BashTool(); console.log(tool.description()); console.log(tool.systemPrompt()); const result = await tool.execute("echo 'Hello!'"); console.log(result.stdout); ``` Framework adapters are exported as subpaths of `@everruns/bashkit`: - OpenAI adapter: `@everruns/bashkit/openai` - Anthropic adapter: `@everruns/bashkit/anthropic` - Vercel AI SDK adapter: `@everruns/bashkit/ai` - LangChain adapter: `@everruns/bashkit/langchain` ## Sandbox guarantees A `BashTool` runs in the same sandbox as the core interpreter, so tool calls inherit Bashkit's safety model: an in-memory virtual filesystem, resource limits on commands and output, and a default-deny network allowlist. The model cannot escape to the host unless you explicitly mount a real filesystem or allow a domain. ## Agent skill Give your coding agent Bashkit-specific context with the published skill: ```sh npx skills add everruns/bashkit ``` [View skill contents](https://github.com/everruns/bashkit/tree/main/skills/bashkit), the `SKILL.md` and reference files (CLI, Rust/Python/TS APIs, builtins, LLM tools, examples) the skill installs. ## Next steps - [Hooks](../crates/bashkit/docs/hooks.md), observe, rewrite, or cancel tool calls and HTTP requests. - [Security](security.md), the sandbox boundaries every tool call runs inside. - Examples: [agent and tool flows](https://github.com/everruns/bashkit/tree/main/examples). - Full API reference: [docs.rs/bashkit](https://docs.rs/bashkit/latest/bashkit/). --- # Script analysis Source: https://bashkit.sh/docs/script-analysis.md `analyze()` parses a script and reports what it *statically* refers to, the commands it invokes, their arguments, the files it redirects to, and the functions it defines. It never executes anything. It exists for the question every agent host has to answer: **the model produced this command, do I run it, or ask the user first?** Available in all three packages: | | Call | |---|---| | Rust | `bash.analyze(script)?` | | Node | `bash.analyze(script)` | | Python | `bash.analyze(script)` | ## The shape ```typescript const analysis = bash.analyze("cat notes.txt | grep -i todo > out.txt"); analysis.commandNames; // ["cat", "grep"] analysis.commands[1].name; // "grep" analysis.commands[1].args; // ["-i", "todo"] analysis.commands[1].context; // "direct" analysis.redirects[0].path; // "out.txt" analysis.redirects[0].isWrite; // true analysis.isOpaque; // false ``` Every word is **literal-or-null**. A command name or argument is reported only when it is fully literal; anything containing an expansion is `null`: ```python analysis = bash.analyze('rm "$target" /tmp/fixed') analysis.commands[0].name # "rm" analysis.commands[0].args[0] # None — "$target" is not knowable statically analysis.commands[0].args[1] # "/tmp/fixed" ``` Partial reconstruction is deliberately not offered: `"/tmp/$name.txt"` reports `None`, not `"/tmp/"`. A half-expanded path reads like a path but is not one. ## Analysis is advisory, not a boundary A script's real behavior is only knowable at runtime. Static analysis cannot see through: - dynamic dispatch, `$cmd`, `${arr[0]}`, `$(echo rm) -rf /` - interpreter re-entry, `eval`, `source`, `.`, and any nested `bash`/`sh` - functions and aliases that rebind a name - arguments built from variables The API reports all of these as **unknown**, never as safe, and rolls them into one flag: ```typescript bash.analyze("$(echo rm) -rf /data").isOpaque; // true (dynamic name) bash.analyze('eval "$payload"').isOpaque; // true (eval) bash.analyze("echo x;".repeat(5000)).isOpaque; // true (walk truncated) ``` **If you gate on an allowlist, you must check `isOpaque`.** "Nothing outside my allowlist appeared in `commands`" plus "not opaque" is a decision. Without the second half it is a bypass, `c=rm; $c -rf /data` contains no disallowed command name. A script that does not parse raises/throws. Treat that as *deny or prompt*, not as "no commands". Enforcement stays where it always was: a command that is not a registered builtin cannot run, the network allowlist gates egress, the mount policy gates the host filesystem, and (in Rust) the `before_tool` hook fires with the **resolved** command name at dispatch time. Analysis decides whether to ask; the sandbox decides what can happen. See the [threat model](./security.md) entry TM-ESC-032. ## Permission prompts The common agent loop, in TypeScript: ```typescript const READ_ONLY = new Set(["ls", "cat", "head", "grep", "wc", "echo", "pwd"]); function needsApproval(script: string): string | null { let analysis; try { analysis = bash.analyze(script); } catch { return "script does not parse"; } if (analysis.isOpaque) return "script builds commands dynamically"; for (const command of analysis.commands) { if (command.isAssignmentOnly) continue; if (command.name == null) return "unknown command"; if (!READ_ONLY.has(command.name)) return `\`${command.name}\` may modify state`; } const write = analysis.redirects.find((r) => r.isWrite); if (write) return `writes to ${write.path ?? "a computed path"}`; return null; // safe to run unattended } ``` The same in Python: ```python READ_ONLY = {"ls", "cat", "head", "grep", "wc", "echo", "pwd"} def needs_approval(bash, script): try: analysis = bash.analyze(script) except BashError: return "script does not parse" if analysis.is_opaque: return "script builds commands dynamically" for command in analysis.commands: if command.is_assignment_only: continue if command.name is None: return "unknown command" if command.name not in READ_ONLY: return f"`{command.name}` may modify state" write = next((r for r in analysis.redirects if r.is_write), None) if write: return f"writes to {write.path or 'a computed path'}" return None ``` ## Fine-grained keys for your own builtin When you expose a domain CLI as a [custom builtin](./custom_builtins_js.md), the useful permission is not "may run `mydata`" but "may run `mydata record delete`". Derive it from the analyzed arguments: ```typescript function permissionKey(script: string): string | null { const command = bash.analyze(script).commands.find((c) => c.name === "mydata"); const [resource, action] = command?.args ?? []; if (resource == null || action == null) return null; // computed → prompt if (["list", "get", "query"].includes(action)) return null; // read-only return `mydata:${resource}:${action}`; } permissionKey("mydata record query 42"); // null permissionKey("mydata record delete 42"); // "mydata:record:delete" permissionKey("cat x | mydata doc write 7"); // "mydata:doc:write" ``` Custom builtins are ordinary command names to the parser, so they analyze like anything else, no registration step. ## Contexts Commands inside substitutions and function bodies are walked, not skipped: `echo $(rm -rf /)` must never look like a bare `echo`. Each command says where it sits: ```python analysis = bash.analyze("cleanup() { rm -rf /data; }\necho $(date)") analysis.functions # ["cleanup"] analysis.commands[0].context # "function_body" — runs only if called analysis.commands[1].context # "substitution" — the $(date) analysis.commands[2].context # "direct" — the echo ``` Filter to `direct` for "what happens now"; use all of them for "anything this script could do". ## Redirects `>`, `>|`, and `&>` report `write`; `>>` reports `append`; `<` reports `read`. Fd duplications (`2>&1`), here-documents, and here-strings name no file and are omitted. `path` is `null` when the target is computed (`> $out`), which, for a mount-boundary check, means "ask". ## Known gap: wrapper commands Commands that run *other* commands named in their arguments, `xargs`, `env`, `timeout`, `find -exec`, `awk 'system(…)'`, analyze as ordinary commands and are not flagged. If you allowlist one of them, treat its arguments as commands yourself. Nested shells (`bash`/`sh`, with `-c` text or a script file) *are* flagged, via `hasInterpreterReentry` / `has_interpreter_reentry`. ## Audit logging Recording intent before execution is useful on its own: when a script fails halfway, the log still says what it was going to do. ```python analysis = bash.analyze(script) audit.record( commands=analysis.command_names, writes=[r.path for r in analysis.redirects if r.is_write], opaque=analysis.is_opaque, ) result = bash.execute_sync(script) ``` ## Limits `analyze()` uses the instance's configured parser limits and input-size limit, a script too large or too deep to execute is also too large or too deep to analyze. The walk records at most 4096 commands plus redirects; past that, `truncated` is set and `isOpaque` becomes true. ## See also - [LLM tools](./llm-tools.md), exposing the sandbox to an agent framework - [Custom builtins (JS)](./custom_builtins_js.md), the commands you expose - [Security](./security.md), sandbox boundaries and the threat model --- # Scripted tool orchestration Source: https://bashkit.sh/docs/scripted-tools.md Give an LLM ten tools and a ten-step task, and you pay for ten round-trips, each call is a separate turn, with the model re-reading context every time. `ScriptedTool` collapses that into one call: the model writes a single bash script that invokes your tools, pipes their output through `jq`, loops, branches, and returns one composed result. Each tool you register becomes a **builtin command** inside a locked-down bash interpreter. The LLM orchestrates them with the full shell grammar it already knows, variables, pipelines, `for`, `if`, instead of a sequence of isolated tool calls. LLM one call bash script pipes · vars · loops logic-only shell get_user --id 1 → callback list_orders --user_id 1 create_discount --pct 10 ## Install `ScriptedTool` ships in its own crate, released in lockstep with `bashkit`: ```bash cargo add bashkit-scripted-tool cargo add bashkit-scripted-tool --features python,typescript # tools.* in python / ts ``` Coming from the old `scripted_tool` feature of `bashkit`? See [Migrating to bashkit-scripted-tool](migrating-scripted-tool.md). ## Building one A tool is a `ToolDef` (name, description, JSON-Schema input) paired with a callback that returns stdout on success or an error string on failure: ```rust,no_run use bashkit::Tool; use bashkit_scripted_tool::{ScriptedTool, ToolArgs, ToolDef}; fn get_user(args: &ToolArgs) -> Result { let id = args.param_i64("id").ok_or("missing --id")?; Ok(format!(r#"{{"id":{id},"name":"Ada","tier":"gold"}}"#)) } # #[tokio::main] # async fn main() -> anyhow::Result<()> { let def = ToolDef::new("get_user", "Fetch user by ID").with_schema(serde_json::json!({ "type": "object", "properties": { "id": {"type": "integer", "description": "User ID"} }, "required": ["id"], })); let tool = ScriptedTool::builder("ecommerce_api") .short_description("User, order, and inventory tools") .tool_fn(def, get_user) .build(); // The LLM sends one script; tools compose with pipes and jq. let out = tool .execution(serde_json::json!({ "commands": "get_user --id 1 | jq -r '.name'" }))? .execute() .await?; assert_eq!(out.result["stdout"], "Ada\n"); # Ok(()) # } ``` Flags parse from the schema: `--id 1` becomes `{"id": 1}` (coerced per the schema's property types). Use `.async_tool_fn(def, cb)` for async callbacks, sync and async tools mix freely in one `ScriptedTool`. The full e-commerce demo lives in [`examples/scripted_tool.rs`](https://github.com/everruns/bashkit/blob/main/crates/bashkit-scripted-tool/examples/scripted_tool.rs). ## Code mode, not a file shell `ScriptedTool` always runs in **logic mode**: bash is the control-flow and data-transformation language, not a [virtual filesystem](filesystem.md) shell. This is a deliberate, narrower sandbox than [`BashTool`](llm-tools.md). | Kept | Rejected | |------|----------| | variables, arrays, functions, arithmetic | file commands (`cat`, `ls`, `cp`, `rm`, `mkdir`, …) | | `if` / `case` / `for` / `while` | path execution (`/tmp/x.sh`, `$PATH` lookup) | | pipelines, heredocs, command substitution | file redirection (`>`, `>>`, `<`) except `/dev/null` | | your tool commands + `help` + `discover` | process substitution | | stdin transforms: `jq`, `grep`, `sed`, `awk`, `sort`, `cut`, `tr`, `wc`, `head`, `tail`, `seq`, `expr` | | Reach for [`BashTool`](llm-tools.md) instead when a virtual filesystem is part of the task. ## Arguments a `ToolDef` cannot express Tool commands are parsed from their JSON Schema, which covers `--key value` and `--key=value` and nothing else. No positionals, no short flags, no `--`, and a missing required field surfaces as a deserialization error after dispatch rather than as usage. When a command needs a real CLI shape, register it as a builtin instead and parse the raw argv yourself: ```rust let tool = ScriptedTool::builder("control_plane") .builtin("get_agent", Box::new(GetAgent)) // clap owns this one .tool_fn(list_agents_def, list_agents) // schema owns this one .build(); ``` `GetAgent` can implement [`ClapBuiltin`](https://docs.rs/bashkit/latest/bashkit/trait.ClapBuiltin.html), so `get_agent a-42 -f json` works, and `get_agent` alone fails at parse time with a real usage message. The builtin runs in the same logic-only shell and gets the same disabled filesystem, so this does not widen what a script can touch. Tool commands win on a name collision, so a builtin cannot shadow one, nor `help` or `discover`. Full example: [`scripted_tool_clap_builtin.rs`](https://github.com/everruns/bashkit/blob/main/crates/bashkit-scripted-tool/examples/scripted_tool_clap_builtin.rs). For schema-parsed commands, adding `"additionalProperties": false` turns an unrecognised flag into an error listing the valid ones, instead of silently keeping it as an unused string property, so `list_agents --limti 10` fails loudly rather than quietly ignoring the limit. ## Runtime discovery The LLM doesn't need every schema in its context up front. Two built-in commands let it explore at runtime: - `help --list`, `help `, `help --json`, names, usage, and machine-readable schemas (enum values, required fields). - `discover --categories | --category X | --tag Y | --search text`, filter by the `tags` / `category` you set on each `ToolDef`. For large tool sets, `ScriptedToolBuilder::compact_prompt(true)` shrinks the system prompt to names + one-liners and defers full schemas to `help`. `ScriptingToolSet` formalises this: in `WithDiscovery` mode it exposes a compact script tool **plus** a companion discover tool, so the model browses schemas before writing a script, ideal alongside other tools or for 50+ tool sets. ## Safety `ScriptedTool` inherits every sandbox guarantee (resource limits, no network unless configured) and adds a disabled filesystem backend and reduced builtin surface. Each `execute()` gets a **fresh** interpreter, so there is no state bleed between calls; persistence is your callbacks' concern (capture an `Arc`). Callback and custom-builtin fatal error strings are sanitised by default so host-side secrets, paths, and stack traces never reach script-visible stderr. Cancellation and resource-limit errors remain visible so callers can act on them. ## See also - [Bashkit as an LLM tool](llm-tools.md), the filesystem-backed `BashTool`. - [Virtual filesystem](filesystem.md), why logic mode disables file access. - Spec: [`knowledge/integrations/scripted-tool-orchestration.md`](https://github.com/everruns/bashkit/blob/main/knowledge/integrations/scripted-tool-orchestration.md). --- # Migrating to bashkit-scripted-tool Source: https://bashkit.sh/docs/migrating-scripted-tool.md `ScriptedTool`, `ToolDef`, `ToolRegistry`, and everything built on them moved out of the `bashkit` crate into **`bashkit-scripted-tool`**. It is published to crates.io and released in lockstep with `bashkit` (same version number). `bashkit` keeps the interpreter and the [`BashTool`](llm-tools.md) contract. The `scripted_tool` feature of `bashkit` is gone (removed after 0.18.2). Behavior is unchanged: same types, same builders, same logic-only shell, same security defaults. ## Rust ### 1. Cargo.toml Before: ```toml [dependencies.bashkit] version = "0.18.2" features = ["scripted_tool", "python", "typescript", "jq"] ``` After: ```toml bashkit = { version = "0.20.1", features = ["python", "typescript", "jq"] } bashkit-scripted-tool = { version = "0.20.1", features = ["python", "typescript", "jq"] } ``` Keep `bashkit` only if you use it directly (`Bash`, `BashTool`, the `Tool` trait). `bashkit-scripted-tool` features: | Feature | Effect | |---------|--------| | `python` | `tools.*` in embedded Python for `ToolRegistry::install` (enables `bashkit/python`) | | `typescript` | `tools.*` in embedded TypeScript for `ToolRegistry::install` (enables `bashkit/typescript`) | | `jq` | `jq` available inside the logic-only shell (enables `bashkit/jq`) | | `tracing` | Debug logs for sanitized callback errors | ### 2. Imports Moved types now come from `bashkit_scripted_tool`. Core types (`Bash`, `BashTool`, `Tool`, `ExecutionLimits`, ...) stay in `bashkit`. Before: ```rust,ignore use bashkit::{ScriptedTool, Tool, ToolArgs, ToolDef, ToolRegistry}; ``` After: ```rust,ignore use bashkit::Tool; use bashkit_scripted_tool::{ScriptedTool, ToolArgs, ToolDef, ToolRegistry}; ``` Moved: `ScriptedTool`, `ScriptedToolBuilder`, `ScriptingToolSet`, `ScriptingToolSetBuilder`, `DiscoveryMode`, `DiscoverTool`, `ToolDef`, `ToolArgs`, `ToolImpl`, `ToolCallback`, `AsyncToolCallback`, `SyncToolExec`, `AsyncToolExec`, `CallbackKind`, `ToolDefExtension`, `ToolDefExtensionBuilder`, `ToolDefInvocationTrace`, `ScriptedExecutionTrace`, `ScriptedCommandInvocation`, `ScriptedCommandKind`, `ToolRegistry`, `ToolRegistryBuilder`, `ToolCall`, `ToolCallDecision`, `ToolCallRequest`, `ToolCallSurface`. ### 3. Registry installation `BashBuilder::tool_registry` is replaced by `ToolRegistry::install`, which takes and returns the builder. Before: ```rust,ignore let bash = Bash::builder().limits(limits).tool_registry(registry).build(); ``` After: ```rust,ignore let bash = registry.install(Bash::builder().limits(limits)).build(); ``` Configure limits on the builder before `install`: Python and TypeScript tool bridges read the builder's execution profile at install time. ## Python Import from `bashkit.scripted`: ```python # Before from bashkit import ScriptedTool # After from bashkit.scripted import ScriptedTool ``` The old import still works for now and emits a `FutureWarning`. Run your tests with `-W error::FutureWarning` to find the remaining call sites. The class, its methods, and `bashkit.langchain.create_scripted_tool` are unchanged. ## JavaScript and TypeScript Import from the `@everruns/bashkit/scripted` entry: ```typescript // Before import { ScriptedTool } from "@everruns/bashkit"; // After import { ScriptedTool } from "@everruns/bashkit/scripted"; ``` The root export is kept as a `@deprecated` alias of the same class, so `instanceof` checks keep working, and editors flag the old import. The `ScriptedToolOptions` and `ToolCallback` types move the same way. `createScriptedTool` stays in `@everruns/bashkit/langchain`. ## See also - [Scripted tool orchestration](scripted-tools.md) - [LLM tools](llm-tools.md) --- # Sandbox configuration & limits Source: https://bashkit.sh/docs/configuration.md Every Bashkit binding runs scripts inside the same sandbox: an in-memory virtual filesystem, no `fork`/`exec`, no host access, and hard resource ceilings. This page covers the knobs that shape that sandbox, resource limits, the filesystem, identity, and the network allowlist. The Rust builder is the reference API; the Python and JavaScript bindings expose the same options through constructor arguments (see the notes at the end). ## Resource limits Use a named profile when you want one coherent baseline across execution, session memory, the managed VFS, network, and compiled embedded runtimes: ```rust use bashkit::{Bash, ExecutionLimits, ExecutionProfile, ExecutionProfileName}; let profile = ExecutionProfile::builder(ExecutionProfileName::Hardened) .execution_limits( ExecutionLimits::new() .max_commands(5_000) // explicit per-field override .max_stdout_bytes(512 * 1024), ) .build()?; let mut bash = Bash::builder().profile(profile).build(); # Ok::<(), bashkit::ExecutionProfileError>(()) ``` The closed names are: - `Standard`, current secure library defaults; the default profile. - `Hardened`, tighter limits across every resource family. The isolated VFS stays writable under tighter quotas. - `Interactive`, current REPL intent: relaxed execution/session counters, with secure memory, VFS, network, and runtime defaults unchanged. Profiles never enable network access. Apply a network allowlist explicitly. Call `profile(...)` before fine-grained builder methods; later calls are intentional overrides. A custom `FileSystem` owns its own quota contract and replaces the profile's managed-VFS limits. Limits are enforced while the script runs, a script that exceeds one is terminated, not allowed to exhaust the host. Set them with `ExecutionLimits`: ```rust use bashkit::{Bash, ExecutionLimits}; let limits = ExecutionLimits::new() .max_commands(1000) .max_loop_iterations(10000) .max_function_depth(16); let mut bash = Bash::builder().limits(limits).build(); ``` ## The filesystem Scripts see a virtual filesystem, never the host disk. Pick a backend and pass it to the builder: ```rust use bashkit::{Bash, InMemoryFs}; use std::sync::Arc; let mut bash = Bash::builder() .fs(Arc::new(InMemoryFs::new())) .build(); ``` See the [Virtual filesystem](filesystem.md) guide for the layering stack (`OverlayFs`, `MountableFs`) and the opt-in `realfs` host-mount backend. ## Identity & working directory ```rust use bashkit::Bash; let mut bash = Bash::builder() .env("HOME", "/home/agent") .cwd("/home/agent") .username("agent") .hostname("sandbox") .build(); ``` ## Network allowlist HTTP for `curl`/`wget` requires the `http_client` feature and an explicit allowlist, outbound requests are denied by default. `http_client` also needs a rustls crypto backend, `ring` (default) or `aws-lc-rs`: ```rust use bashkit::{Bash, NetworkAllowlist}; let mut bash = Bash::builder() .network(NetworkAllowlist::new().allow("https://api.github.com")) .build(); ``` See [Networking](networking.md) for per-domain control, and [Security](security.md) for the full list of sandbox boundaries. ## Other bindings The Python and JavaScript bindings take the same options as constructor arguments rather than a builder. For example, in JavaScript: ```typescript import { Bash, ExecutionProfile } from "@everruns/bashkit"; const bash = new Bash({ profile: ExecutionProfile.Hardened, cwd: "/home/agent", env: { HOME: "/home/agent" }, maxCommands: 1000, maxLoopIterations: 10000, maxMemory: 64 * 1024 * 1024, }); ``` Python exposes the same selector as an enum: ```python from bashkit import Bash, ExecutionProfile bash = Bash(profile=ExecutionProfile.Hardened, max_commands=5_000) ``` The native Node binding and browser-WASM package expose a closed `ExecutionProfileName` union. C ABI v1 accepts `"profile": "hardened" | "standard" | "interactive"` in its versioned JSON object and rejects unknown values. Browser WASM has no network or embedded-runtime surface; C ABI v1 has no network/runtime callbacks; profiles only cover capabilities each documented binding supports. Scripted tools always remain logic-only even when a profile is selected. The [Python](start-python.md) and [Node](start-node.md) quickstarts show the per-language constructor options. ## See also - [Get started](start.md), pick your target and run a first script. - [Virtual filesystem](filesystem.md), the VFS backends and layering. - [Networking](networking.md), the HTTP allowlist in depth. - [Security](security.md), sandbox boundaries and threat model. --- # Virtual filesystem Source: https://bashkit.sh/docs/filesystem.md Every Bashkit script runs against an in-memory **virtual filesystem** (VFS), not the host disk. `cat`, `ls`, `cp`, redirections, `mkdir`, they all work exactly as a script expects, but the bytes live in memory and disappear when the interpreter is dropped. Path traversal like `../../../etc/passwd` is normalised away, and symlinks are stored but never followed. The host is invisible by default; you grant access deliberately, never by accident. This is the foundation of the [security model](security.md): there is no real filesystem to escape to unless you mount one. ## The layering stack A `Bash` instance composes its filesystem from layers. Each layer wraps the one below it, so you can stack read-only enforcement, text mounts, and host mounts over an in-memory base, and swap mounts at runtime. MountableFs · live mounts ReadOnlyFs · optional OverlayFs · text mounts MountableFs · real mounts base · InMemoryFs or custom Bash::mount() / unmount() readonly_filesystem() mount_text() mount_real_*_at() Bash::new() ## Two-layer trait model Internally the VFS splits raw storage from POSIX semantics: | Layer | Trait | Responsibility | |-------|-------|----------------| | Backend | `FsBackend` | Raw storage operations and failure-atomic mutations | | POSIX | `FileSystem` / `PosixFs` | POSIX-like validation (no duplicate names, type-safe ops, parent-dir rules) | If you want a custom backend (a database, object store, key-value store), implement the small `FsBackend` and wrap it in `PosixFs`, the POSIX checks come for free. Implement `FileSystem` directly only when you need full control over semantics. `PosixFs` validates operations before delegating them, but it cannot make a raw backend mutation atomic. A custom `FsBackend` must make failed `write`, `copy`, and `rename` operations failure-atomic; a direct `FileSystem` has the same obligation for `write_file`, `copy`, and `rename`. Errors must leave source and destination entries, bytes, types, and reported usage unchanged. A wrapper moving entries between independent backends must restore the previous destination on failure or reject the move before mutation, usually with `ErrorKind::CrossesDevices`. `verify_filesystem_requirements()` is a structural smoke check for root access and path normalization. It does not mutate the filesystem or certify failure atomicity, symlink behavior, quota accounting, or error normalization; custom adapter tests remain responsible for those invariants. ## Built-in implementations | Implementation | Purpose | |----------------|---------| | **InMemoryFs** | Default (`Bash::new()`). `HashMap`-backed, thread-safe, no persistence. Seeds `/`, `/tmp`, `/home`, `/home/user`, `/dev`. | | **OverlayFs** | Copy-on-write over another filesystem, with whiteout tracking for deletes. | | **MountableFs** | Mount multiple filesystems at different paths (longest-prefix match). Always the outermost layer, enabling [live mounts](../crates/bashkit/docs/live_mounts.md). | | **NamespaceFs** | Compose a static visible tree from rebased filesystem subtrees, with per-mount access and synthetic ancestors. | | **ReadOnlyFs** | Delegates reads, denies every mutation with `PermissionDenied`, even writes to `/tmp`, `cp`, `mv`, `rm`, `chmod`. For inspection-only sessions. | | **RealFs** (`realfs` feature) | Direct access to a host directory. Read-only (safe) or read-write (dangerous); path traversal blocked by canonicalisation + root-prefix checks. | ## Mounting host directories Real host access is **opt-in and read-only by default**. The `realfs` feature adds builder and CLI entry points: ```rust,no_run use bashkit::Bash; # fn main() -> bashkit::Result<()> { let mut bash = Bash::builder() .mount_real_readonly("/host/data") // visible read-only inside the VFS .build(); # let _ = bash; # Ok(()) # } ``` ```bash bashkit --mount-ro /host/data:/data -c 'ls /data' # read-only bashkit --mount-rw /host/out:/out -c 'echo hi > /out/f' # writable (dangerous) ``` To freeze a session, including in-memory writes, wrap it with `readonly_filesystem()`. ## Composing a static namespace `NamespaceFs` creates an intentionally bounded path tree instead of a fallback root plus live mounts. Each source may be rebased and independently read-only or read-write: ```rust use bashkit::{Bash, FileSystem, InMemoryFs, NamespaceFs}; use std::path::Path; use std::sync::Arc; # #[tokio::main] # async fn main() -> bashkit::Result<()> { let repository = Arc::new(InMemoryFs::new()); repository.mkdir(Path::new("/repo/src"), true).await?; repository.write_file(Path::new("/repo/src/lib.rs"), b"source").await?; let output = Arc::new(InMemoryFs::new()); let namespace = NamespaceFs::builder() .mount_readonly_from("/src", repository, "/repo/src")? .mount_readwrite("/build", output)? .build(); let mut bash = Bash::builder().fs(Arc::new(namespace)).build(); assert_eq!(bash.exec("cat /src/lib.rs").await?.stdout, "source"); # Ok(()) # } ``` Nested targets use longest-prefix precedence. Missing ancestors and mount points are visible as directories. Files and symlinks can be copied across mounts; cross-mount rename reports a typed cross-device error because copy-delete is not atomic. ## Host-backed filesystem (JS) The wasm bindings accept an `fs` object, so scripts run directly against storage you own, a Durable Object, an OPFS handle, IndexedDB, instead of the in-memory VFS. Nothing is copied in or diffed back out: every read and write during the run is a call into your object. ```js const bash = new Bash({ cwd: "/workspace", fs: myHost }); const r = await bash.execute("grep -rl TODO . | head -5"); ``` Seven methods are required, `read`, `write`, `mkdir`, `remove`, `stat`, `readDir`, `exists`, and each may return its value directly or as a `Promise`. `append`, `copy`, `rename`, and `chmod` are optional and synthesized from the required primitives when omitted. Your host implements raw storage only; POSIX semantics (parent-directory checks, "is a directory", symlink resolution) are enforced above it. Throw an `Error` carrying a `code` (`ENOENT`, `EEXIST`, `EACCES`, …) so bash reports the failure the way a real shell does. Two contract notes: - **`execute()` only.** A host call can suspend the interpreter, and `executeSync` cannot await, it reports the suspension instead of blocking. - **`files` is rejected alongside `fs`.** Seeding writes through the VFS synchronously, which a promise-returning host can never satisfy. The host object is the security boundary and is yours to scope (mount root, allowlist, read-only): paths are normalized before any host call, so traversal cannot select a path you did not expose, but the sandbox reaches whatever the object exposes. Reads and writes bypass the in-memory quotas. See [`@everruns/bashkit-wasm`](https://github.com/everruns/bashkit/blob/main/crates/bashkit-wasm/README.md#host-backed-filesystem) for the full method table and error-code list. ## System directories By default a read-only system layer sits just above the base, so scripts see a Linux-like root: `/etc` (`os-release`, `passwd`, `group`, `hostname`, `hosts`), a static `/proc` (`cpuinfo`, `meminfo`, `version`, `loadavg`), `/bin` and `/usr/bin` entries for every builtin, `/root` and `/dev/zero`. ```bash which ls # /usr/bin/ls grep ^ID= /etc/os-release # ID=bashkit /usr/bin/env FOO=1 env # stubs run the builtin head -c 4 /dev/zero | od -An -tx1 ``` Everything is synthetic and built from session config (username, hostname), never read from the host. The synthetic `/etc/passwd` has no root entry. Your own files win: anything in the base filesystem or a mount shadows the system layer, and new files under `/etc` are created in your filesystem. System files can't be modified or removed. The layer costs no quota and is not part of snapshots. Turn it off with `Bash::builder().rootfs(false)`. ## Special files and symlinks - **`/dev/null`** is handled at the interpreter level (not the filesystem), so a custom backend can't intercept it. **`/dev/urandom`** / **`/dev/random`** return bounded random data; **`/dev/zero`** returns 1 MiB of zeros per read. - **Symlinks** are followed like on Linux (`cat link`, `cd linkdir`, `echo x > link`), but a target is always a VFS path: absolute targets start at the VFS root and `..` stops there, so a link can't reach the host (TM-ESC-002). A lookup gives up after 40 links with "Too many levels of symbolic links" (TM-DOS-011). `rm`, `mv`, `readlink`, `[ -L ]` and `ls -l` act on the link itself. `Bash::builder().follow_symlinks(false)` restores store-only links. ## Binding parity Every language binding exposes the same concepts, so the model is identical from Rust, Python, and Node: ```text files: { "/path": "content" } # writable in-memory text files mounts: [{ host_path, vfs_path?, writable? }] # real FS (read-only by default) readonly_filesystem: bool # deny all VFS mutations after setup ``` The wasm bindings additionally accept `fs`, an embedder-supplied filesystem that replaces the in-memory VFS entirely. See [Host-backed filesystem (JS)](#host-backed-filesystem-js) above. ## See also - [Security](security.md), the boundaries built on top of the VFS. - [Live mounts](../crates/bashkit/docs/live_mounts.md), attach and detach filesystems at runtime. - [Filesystem namespaces](https://docs.rs/bashkit/latest/bashkit/namespace_filesystems_guide/), compose and rebase static mount trees. - [Snapshotting](snapshotting.md), serialise and restore VFS + shell state. - Spec: [`knowledge/foundations/vfs.md`](https://github.com/everruns/bashkit/blob/main/knowledge/foundations/vfs.md). --- # In-process terminal Source: https://bashkit.sh/docs/terminal.md Bashkit can run an interactive shell session on an in-memory terminal. You send keystrokes and read back what the screen shows, as plain text, without a real PTY, process, or host terminal. Full-screen programs work too: the built-in `vi` edits files in the virtual filesystem. This recording is the session's real output: every keystroke went through `send()` and every byte shown came from `take_output()` ([how it was recorded](#recording-a-session)).
Use it when a one-shot `exec()` is not enough: - an LLM agent that should operate a shell the way a person does, including editing a file in `vi`, and read the screen after each step; - a browser or desktop app that renders a terminal (for example with xterm.js) backed by the sandbox; - tests that assert on what a user would see. The terminal is behind the `terminal` cargo feature, off by default: ```bash cargo add bashkit --features terminal ``` ## Quick start ```rust use bashkit::Bash; use bashkit::terminal::{Terminal, TerminalSize, TerminalStatus}; #[tokio::main] async fn main() { let mut term = Terminal::with_size(Bash::builder(), TerminalSize::new(24, 80)); term.run_until_idle().await; // draws the first prompt term.send("vi /tmp/notes.txt\r"); // type a command, press Enter term.run_until_idle().await; // runs until vi waits for keys println!("{}", term.screen_text()); // the editor screen, as text term.send("ihello\x1b:wq\r"); // insert, Escape, save and quit term.run_until_idle().await; let saved = term.fs().read_file("/tmp/notes.txt".as_ref()).await.unwrap(); assert_eq!(saved, b"hello\n"); term.send("exit\r"); assert_eq!(term.run_until_idle().await, TerminalStatus::Exited(0)); } ``` A runnable version lives in [`crates/bashkit/examples/terminal_vi.rs`](../crates/bashkit/examples/terminal_vi.rs): `cargo run --example terminal_vi --features terminal`. ### Recording a session `take_output()` is a byte-exact stream, so a session can be recorded and replayed in any terminal player. [`crates/bashkit/examples/terminal_record.rs`](../crates/bashkit/examples/terminal_record.rs) types a scenario key by key (a `vi` edit and `less` paging) and writes an [asciicast v2](https://docs.asciinema.org/manual/asciicast/v2/) file: ```bash cargo run --example terminal_record --features terminal -- demo.cast asciinema play demo.cast ``` The recording at the top of this page is that file, served from `site/public/casts/terminal-demo.cast`. Re-record it after changing terminal output by running the example with that path. ## How it works `Terminal` owns a `Bash` and a virtual terminal device. Nothing runs in the background: 1. `send(bytes)` queues input, exactly as if typed. 2. `run_until_idle()` runs the session until it is blocked waiting for input with nothing queued, then returns `TerminalStatus::Idle`, or `TerminalStatus::Exited(code)` once the shell exits (`exit`, or Ctrl-D on an empty line). 3. You read the result and decide what to type next. "Idle" is the moment a person would look at the screen, so an agent loop is simply send, run, read. `run_until_idle()` is cancellation-safe: wrap it in `tokio::time::timeout` to stop waiting on a long command, send Ctrl-C, and call it again. ### Reading results | Method | Returns | |--------|---------| | `screen_text()` | The visible screen as plain text, one line per row, trailing blanks trimmed | | `history_text()` | The screen plus up to 1000 lines of scrollback above it, same format | | `take_transcript()` | Commands finished since the last call: command line, exact output, exit code | | `activity()` | `Prompt`, `ContinuationPrompt`, `Running { command }` (for example an open `vi`) or `Exited(code)` | | `cursor()` | Cursor `(row, col)`, zero-based | | `is_alternate_screen()` | `true` while a full-screen program such as `vi` is open | | `input_prompt()` | While `read` or `select` waits for a typed line, the question on the cursor line (`"Continue? [y/N] "`); otherwise `None` | | `take_output()` | Raw bytes (with escape sequences) produced since the last call, for a renderer like xterm.js | | `fs()` | The session's virtual filesystem, to read files commands or `vi` wrote | | `exit_code()` | The shell's exit code once it has exited | For most agent use, `screen_text()` plus `fs()` is all you need: the screen shows what happened, and the filesystem holds what was saved. When an agent needs exact results instead of screen text, use the transcript. Each `CommandRecord` holds the command line, its combined stdout and stderr with plain `\n` line endings, and its exit code (`130` after Ctrl-C, `2` for a syntax error). Output that scrolled off the screen is still there, and no prompt scraping is needed: ```rust let mut term = Terminal::new(Bash::builder()); term.send("ls /nope\r"); term.run_until_idle().await; let record = &term.take_transcript()[0]; assert_eq!(record.command, "ls /nope"); assert_ne!(record.exit_code, 0); assert_eq!(term.activity(), TerminalActivity::Prompt); ``` Full-screen programs (`vi`, `less`) draw straight to the terminal, so their screens are not in the transcript; read them with `screen_text()`. Each record keeps up to 64 KiB of output (`output_truncated` says when more was dropped), and untaken records are capped at 1 MiB in total, oldest first. ### Sending keys | Key | Bytes | |-----|-------| | Enter | `\r` | | Escape | `\x1b` | | Backspace | `\x7f` | | Ctrl-C / Ctrl-D | `\x03` / `\x04` | | Arrow up/down/right/left | `\x1b[A` `\x1b[B` `\x1b[C` `\x1b[D` | At the prompt the terminal behaves like a normal line-mode terminal: typed characters echo, Left/Right, Home/End (or Ctrl-A/Ctrl-E) move the cursor, Backspace, Delete, Ctrl-U (kill to start), Ctrl-K (kill to end) and Ctrl-W (kill word) edit the line, Up/Down recall earlier commands from this session, Ctrl-C discards the line, and an incomplete command (`for i in 1 2; do`) shows the `PS2` prompt and waits for more lines. Shell state persists between lines, as in any `Bash` session. `PS1` and `PS2` are honoured (`\u \h \w \W \$`); the default prompt is `$ `. `[ -t 0 ]` is true inside the session, and `COLUMNS`, `LINES`, `TERM` and `EDITOR` (`vi`) are set. `git commit` without `-m` opens the editor on the commit message, as in real git: save and quit to commit, `:cq` or an empty message aborts. `vi`, `vim` and `nano` work as `$EDITOR`. `resize()` changes the size; a running `vi` redraws. ## As an LLM tool `TerminalTool` wraps one session as a tool an agent can call repeatedly. Keys go in one string in Vim notation, and each call returns the screen, what the session is doing, and the commands that finished during the call: ```rust use bashkit::Bash; use bashkit::terminal::TerminalTool; use serde_json::json; let mut tool = TerminalTool::new(Bash::builder()); // Register tool.tool_definition() (OpenAI function format) and // tool.system_prompt() with your model, then forward its calls: let out = tool.call(json!({"input": "vi notes.txt"})).await?; // out["activity"] == "running", out["full_screen"] == true let out = tool.call(json!({"input": "ihello:wq"})).await?; // out["activity"] == "prompt", out["commands"][0]["exit_code"] == 0 ``` | `input` token | Key | |---------------|-----| | plain text | typed as-is | | `` `` `` `` `` `` | the named key | | `` `` `` `` `` `` `` `` | cursor keys | | ``, ``, any `` | Ctrl plus a letter | | `` | a literal `<` | Anything else in angle brackets, such as `` or a heredoc's `<<`, is typed literally. The result has `screen`, `activity` (`prompt`, `continuation`, `running`, `input` or `exited`; `input` means a command such as `read` asks a question, given in `input_prompt`), `running_command` or `exit_code` when they apply, `full_screen` (true while `vi` or `less` is open), `waiting_for_input`, and `commands` (the transcript records finished during the call). Each call waits up to `wait_ms` (default 5 s, at most 60 s) for the session to need input. A command still running after that is reported with `waiting_for_input: false`, not killed: call again, with empty input, to keep waiting, or send ``. One `TerminalTool` is one session, so keep it for the whole conversation. `call` takes at most 64 KiB of `input` per call. Two optional arguments keep agent loops short: - `wait_for`: a regex. The call returns as soon as output printed by commands during the call matches, even while the command keeps running, and sets `matched`. Typed keys and prompts are not matched, so `{"input": "./serve.sh", "wait_for": "listening on"}` returns when the server prints its line. `^` and `$` match at line ends. - `screen`: `"full"` (default) returns `screen`; `"changes"` returns `screen_changes` (`[{row, text}]`, only rows that differ from the previous call) and `cursor`; `"none"` returns no screen. ### Several sessions `TerminalTool::with_sessions(size, factory)` lets the agent open terminal tabs with the `session` argument (`"main"` by default, up to 8). A new name opens a new shell from `factory` that shares the main session's files; variables, the working directory and jobs stay per session. While a call waits on one session the others keep running, so a loop or `watch` in one tab progresses while the agent works in another. Results carry `session` and `sessions` (each tab and its activity) once more than one is open. `close: true` closes a tab, and a tab whose shell exited starts a fresh one the next time keys are sent to it. The Python and JavaScript `Terminal` classes always support sessions. ```rust let mut tool = TerminalTool::with_sessions(TerminalSize::default(), Box::new(Bash::builder)); tool.call(json!({"session": "logs", "input": "watch -n1 'tail -3 /tmp/app.log'", "wait_ms": 0})).await?; tool.call(json!({"input": "./run.sh"})).await?; // "logs" keeps refreshing meanwhile ``` It is not a `BashTool`: that tool runs each call in a fresh shell, while a terminal keeps the shell, open programs and the screen between calls. ## From Python The `bashkit` Python package ships the terminal as `bashkit.Terminal`, with the same calls as the Rust type plus the agent-style `call`: ```python from bashkit import Terminal t = Terminal(rows=24, cols=80, cwd="/tmp") out = t.call("vi notes.txt") # Vim key notation assert out["activity"] == "running" and out["full_screen"] out = t.call("ihello:wq") assert out["commands"][0]["exit_code"] == 0 assert t.fs().read_file("/tmp/notes.txt") == b"hello\n" # Low level: raw keys, bounded waits, screen and transcript. t.send("seq 1 100\r") t.run_until_idle(timeout=5) # "idle", "exited" or "timeout" print(t.screen_text(), t.history_text(), t.take_transcript(), t.activity()) ``` `tool_definition()` and `system_prompt()` return the same tool metadata as `TerminalTool`. Calls are synchronous and release the GIL while the session runs; `timeout` bounds the wait. ## From JavaScript The `@everruns/bashkit` package (Node, Bun, Deno) exports the same class: ```typescript import { Terminal } from "@everruns/bashkit"; const term = new Terminal({ rows: 24, cols: 80, cwd: "/tmp" }); let out = await term.call("vi notes.txt"); // Vim key notation out = await term.call("ihello:wq"); console.log(out.commands[0].exit_code); // 0 console.log((await term.readFile("/tmp/notes.txt")).toString()); // "hello\n" // Low level: raw keys, bounded waits, screen and transcript. term.send("sleep 10; echo done\r"); await term.runUntilIdle(100); // "idle", "exited" or "timeout" term.send("\x03"); // Ctrl-C while the command runs await term.runUntilIdle(); console.log(term.screenText(), term.takeTranscript(), term.activity()); ``` `runUntilIdle` and `call` are async and yield between short slices, so `send`, `screenText` and the other sync methods work while a command runs. `toolDefinition()` and `systemPrompt()` return the `TerminalTool` metadata. ## vi `vi [FILE]` opens the editor on the alternate screen; quitting restores the shell screen. It supports: - **Modes:** normal, insert, and the `:` / `/` command line. - **Movement:** `h j k l`, arrows, `w b e`, `0 ^ $`, `gg`, `G`, `NG`, `:N`, Enter, `+ -`. Counts work (`3j`, `2dd`). - **Editing:** `i a I A o O`, `x X`, `dd dw de db d$ d0 D`, `cc cw C s S`, `yy yw Y`, `p P`, `r`, `J`, `u`, Ctrl-R. - **Search and replace:** `/pattern`, `n N`, `:s/pat/rep/`, `:%s/pat/rep/g` (regex patterns; `&` in the replacement is the match). - **Files:** `:w`, `:w FILE`, `:q`, `:q!`, `:wq`, `:x`, `ZZ`, `ZQ`, `:cq` (quit with exit status 1). `:q` refuses to discard unsaved changes, as in vim. Not supported: visual mode, named registers, macros, splits, vimrc, and `:!` (no shell escape from inside the editor). See L-TERM-001 in the [limitations](../knowledge/operations/limitations.md#terminal). `vi` needs a terminal. Under plain `Bash::exec()` it exits 1 with `vi: not a terminal`. ## nano `nano FILE` is a small GNU-nano-style editor: type to insert, move with the arrow keys, Home/End and PageUp/PageDown, `^O` to write (Enter accepts the name), `^X` to exit (it asks to save a modified buffer: `Y`, `N` or `^C`), `^K` and `^U` to cut and paste lines (repeated `^K` collects several), `^W` to search, `^G` for help and `^C` for the cursor position. `EDITOR=nano` works for `git commit`. Not supported: undo, multiple buffers, syntax colors and nanorc (L-TERM-005 in the [limitations](../knowledge/operations/limitations.md#terminal)). ## less and more `less` and `more` page interactively inside a terminal session, from files or a pipe (`git log | less`, `seq 1 1000 | more`). - **less** uses the alternate screen. Keys: `q` quit, space/`f`/PageDown next page, `b`/PageUp previous page, `j`/Enter/Down and `k`/Up by line, `d`/`u` half page, `g`/`G` top/bottom, `/pattern` and `?pattern` search (regex), `n`/`N` repeat. The status line shows the file name, `:` or `(END)`. `-F` prints input that fits on one screen and exits. - **more** scrolls on the normal screen with a `--More--(NN%)` prompt: space for the next page, Enter for the next line, `q` to stop. Input that fits on one screen is printed directly. `man CMD` shows a command's `--help` text as a manual page, in `less` inside a session (plain output elsewhere). Shell builtins such as `cd` use `help CMD`. `watch [-n SECS] [-t] [-g] [-x] COMMAND` reruns the command on the alternate screen until Ctrl-C, or until its output changes with `-g`. Each run and each interval count against the execution timeout, like `sleep`; outside a session it prints a notice instead of looping. Control characters in content show in caret notation (`^[`), so a file cannot send escape sequences to your terminal. Outside a terminal session (`Bash::exec()`, `BashTool`, the CLI), `less` and `more` behave like `cat` and never wait for input, so existing scripts and tools are unaffected. Inside a session they page even when stdout is redirected (`less file > out`). ## In the browser The npm package [`@everruns/bashkit-wasm`](start-browser.md) exposes the same session as a `Terminal` class with `send`, `runUntilIdle`, `takeOutput`, `screenText`, `resize` and `fs`. Wire `takeOutput()` into [xterm.js](https://xtermjs.org) and forward its `onData` keystrokes to `send`. The [playground](https://bashkit.sh/playground) on bashkit.sh is built exactly this way; its source is `site/src/playground/client.ts`. ## Limits and security Everything runs inside the normal sandbox: the same virtual filesystem, the same execution limits, no host processes. A few terminal-specific rules apply: - **Timeouts.** Time spent waiting for keystrokes does not count against `ExecutionLimits::timeout`, so a `vi` session is not killed while an agent thinks. CPU work and `sleep` still count. - **Bounded buffers.** Unread input is capped at 1 MiB (`send` returns how many bytes it accepted), retained raw output at the newest 4 MiB, and the `vi` buffer at 8 MiB. - **Ctrl-C** stops a running command right away, even inside `sleep`, and sets `$?` to 130. - **Typed input.** `read` and `select` wait for a line typed on the terminal (`read -p`, `-s`, `-n N` and `-t SECS` work). Other commands with no input file, such as `cat`, get end-of-file instead of waiting. See TM-DOS-119 and TM-DOS-120 in the [threat model](../crates/bashkit/docs/threat-model.md). ## See also - [Browser (WASM)](start-browser.md): the `Terminal` class in `@everruns/bashkit-wasm` - [CLI](cli.md): the `bashkit` binary's own interactive REPL on your real terminal - [Snapshotting](snapshotting.md): persist and restore a session's state - [Virtual filesystem](filesystem.md): where `vi` reads and writes files --- # Security Source: https://bashkit.sh/docs/security.md Bashkit is a virtual Bash interpreter designed for safe, sandboxed script execution. Security is a first-class concern, every design decision considers what an untrusted script could do and how to prevent it. This article gives a high-level overview. For the full threat model with individual threat IDs and mitigation status, see the [rustdoc threat model guide](https://docs.rs/bashkit/latest/bashkit/threat_model/index.html). ## Core security boundaries | Boundary | What it does | |----------|-------------| | **Virtual filesystem** | Scripts run against an in-memory VFS. No real filesystem access by default. Path traversal (`../../../etc/passwd`) is normalised away. Symlinks are followed inside the VFS only: a target is always a VFS path, so a link can never reach the host. | | **No process execution** | `bash -c` and virtual `exec` execute inside the interpreter. Background jobs (`&`) run concurrently under the shared job cap. | | **Network allowlist** | HTTP/HTTPS only, pre-validated against an explicit host allowlist. No DNS resolution, no auto-redirect, no auto-decompression. | | **Resource limits** | Configurable caps on commands, loop iterations, recursion depth, AST depth, timeouts, and parser operations prevent denial-of-service from malicious scripts. | | **Filesystem limits** | Total bytes, per-file size, file count, path depth, and filename length are all capped to prevent storage exhaustion (zip bombs, tar bombs, recursive copies). | Concurrent pipeline producers share `ExecutionLimits::max_background_jobs` with background jobs, including nested work. All producer slots are admitted before constructing the pipeline, and remain reserved until its producers are released. Owned fork storage and producer futures count against `max_live_intermediate_bytes` before allocation; construction also checks the shared work budget, cancellation and deadline. The final stage needs no extra job slot. Exceeding a limit returns a resource-limit error. Pipelines within these budgets keep their streaming, isolation and `PIPESTATUS` behavior; `concurrent_jobs(false)` uses sequential stages without producer slots. Builtin file operands for `/dev/stdout`, `/dev/stderr`, `/dev/fd/1` and `/dev/fd/2` share the request's `max_live_intermediate_bytes` budget. Capture reserves capacity before each write or append and obeys `max_stdout_bytes` / `max_stderr_bytes` before growth and combined collection. Cached text and stdin copies are also admitted; collection retains its storage charges through redirects and hooks. Every write checks work, cancellation and deadlines. Refused admission returns a resource-limit error even when output would be discarded by a redirect. Ordinary aliases and append/copy semantics remain unchanged within the configured budgets. Command-local file-input redirections reserve reported file sizes before opening or reading, and admit cached text and descriptor copies before growth. Both representations count toward `max_live_intermediate_bytes` and aggregate input consumption. Storage charges survive with stdin; replacing a numbered descriptor releases its old storage without resetting aggregate consumption. Admission uses the original backend path for stat/open/read, preserving mounted namespaces, symlink policy, and read-only diagnostics; auxiliary device-path canonicalization does not require synthetic mount-parent entries. Budget and cancellation errors abort the request, while ordinary missing-file errors remain shell command failures. Custom filesystems must report reliable read sizes; backend-internal allocations are still the backend's responsibility. `dd conv=sync` checks its padded block length against the 64 MiB copy cap before allocating or zero-filling. Copied payload capacity also counts against `max_live_intermediate_bytes` until output handling completes, including when output is discarded. Stdout cached text is admitted under the same budget. Exceeding either limit returns a resource-limit error. `pr` checks its 16 MiB output cap before appending headers, rows, margins, newlines or form feeds. Repeated long headers cannot grow the output buffer past that cap. Input dates are retained as timestamps, so long date formats are not copied for every input, including empty files. Input, row and output storage also share `max_live_intermediate_bytes`; work, aggregate input, cancellation and deadline checks apply during pagination. Exceeding a limit returns a resource-limit error. A subsequent host execution can reuse the session with a fresh execution budget. `od -w` / `od --width` accepts at most 65,536 bytes per row. Larger widths fail before rendering, even with empty input. Numeric fields use at most eight bytes of stack padding; output capacity is admitted against `ExecutionLimits::max_live_intermediate_bytes` before fallible allocation. This memory budget applies to pipelines and redirected output too. Shell diagnostic names (`$0` or the current source file) show at most 1,024 UTF-8 bytes; the actual `$0` value is unchanged. Repeating a prefix across builtin errors uses the shared live-memory and work budget before allocation, including the cached text representation. A refused reservation aborts the request before capture, streaming, or redirection; the next host execution can reuse the shell with a fresh budget. Capture limits remain downstream so command-local redirected diagnostics keep their full content when they fit the memory budget. An outer redirect on a child-shell invocation receives that child's already capped capture. `fmt` reserves output capacity against `max_live_intermediate_bytes` before allocation, including repeated indentation and prefixes. Generated bytes and optimal-fit searches consume the shared work budget and check cancellation and deadlines. Exceeding a budget returns a resource-limit error. Output within the budget preserves its formatting through pipelines and file redirects, even when it exceeds the stdout capture limit. `join` preserves matching-row Cartesian products within the execution budget. Input rows, keys and generated output reserve live-memory capacity before allocation. Parsing, key grouping and every emitted field consume shared work and check cancellation and deadlines. Budget failures abort the request with a resource-limit error; a later host execution starts with a fresh budget. Stdout capture limits remain downstream, so pipelines and redirects receive complete join output when it fits the execution budget. Make's `$(subst)` checks its exact replacement length against the remaining 4 MiB expansion allowance before constructing the result. Its generated arguments, output and parent expansion buffers reserve capacity against `max_live_intermediate_bytes` before growth. Expanded variable values retain their storage leases until replacement or the end of the Make run; shared parser views share the same storage. Matching and copying consume the request's work budget and check cancellation and deadlines. Empty patterns and ordinary Unicode substitution keep their existing behavior. `cd` searches `CDPATH` one candidate at a time. Temporary path and normalization storage is reserved against `max_live_intermediate_bytes` before allocation. Searches also consume the shared work budget and check cancellation and deadlines, including when every candidate is missing. Arrays share the retained-variable byte budget and the session array-entry limit. Replacing or clearing `mapfile` arrays releases their old charges, so repeated reads do not consume capacity cumulatively. Entry or byte exhaustion returns a resource-limit error; earlier admitted records may remain. A later execution can recover by unsetting arrays to free capacity. `mapfile` rejects interpreter-owned debug-stack arrays as destinations. `FUNCNAME` remains reserved even after unset; Bash allows it to become an ordinary array in that case. Indexed compound-array assignments expand values before evaluating subscripts. Pending keys, values, fields and container capacity count against `max_live_intermediate_bytes`, including repeated writes to the same index. Exceeding that budget returns a resource-limit error even when the final array would fit the retained-variable budget. Command hash entries (`hash -p FILE NAME...` and automatic PATH lookups) charge each retained name and pathname against the same live-byte budget before copying. Entries that survive between executions count toward the next execution's budget. Deleting, replacing, evicting or clearing entries releases their storage; subshells share pathname storage while maintaining independent hash state. The table also retains its 512-entry ceiling. `MemoryLimits::max_function_body_bytes` also includes retained function-definition filenames and metadata keys. Filename storage is shared; each function is charged conservatively before insertion. An oversized `source` operand therefore fails with a resource-limit error even when redundant slashes normalize to a short VFS path. Redefinition replaces the charge, `unset` releases it, and subshell rollback restores filenames together with functions. Executable child scripts discard their own metadata on return. Functions and their admitted metadata persist across executions; shell-state restore discards previous filenames because the serialized state carries function source without definition filenames. Recursive `grep -R` skips directory symlinks that resolve to an ancestor and reports `warning: recursive directory loop` (suppressed by `-s`). Separate aliases of a non-ancestor directory remain searchable. Traversal uses the shared execution work and deadline budget; file contents are admitted against aggregate input and live intermediate limits and scanned one file at a time. `-q` stops at the first match, before further traversal. Tilde expansion (`~`, `~+`, `~-`, including repeated assignment prefixes after `:`) checks `ExecutionLimits::max_live_intermediate_bytes` before growing its output and charges copied bytes to `max_work_units`. Large directory values cannot bypass these limits by expanding before variable insertion. A refused expansion returns a resource-limit error; the next host execution receives a fresh execution budget. Syntax-error reports are limited to 1 KiB during construction, including an explicit `[diagnostic truncated]` marker when text is omitted. This prevents multiline unexpected tokens and long `bash -c` shell names from multiplying host-memory usage before stderr capture. Ordinary short reports retain Bash's text, line numbers, and exit status. ## Threat model Prompt expansion (`${x@P}` and interactive prompts) shares the execution work, aggregate-input, cancellation and deadline budgets. Recursive prompt reparses stop at `min(max_ast_depth, 8)` with a resource-limit error, including cycles through command substitutions. Raising the AST limit cannot remove this host stack protection. Finite prompt expansion and `shopt -u promptvars` retain their normal behavior within these limits. Bashkit maintains a living threat model in [`knowledge/security/threat-model.md`](../knowledge/security/threat-model.md) with stable threat IDs across these categories: | Category | ID prefix | Examples | |----------|-----------|----------| | Denial of Service | `TM-DOS` | Resource exhaustion, infinite loops, parser bombs | | Sandbox Escape | `TM-ESC` | Path traversal, real FS access, privilege escalation | | Information Disclosure | `TM-INF` | Secret leakage, host info exposure, data exfiltration | | Injection | `TM-INJ` | Command injection, variable namespace pollution | | Network | `TM-NET` | DNS rebinding, allowlist bypass, response flooding | | Multi-Tenant Isolation | `TM-ISO` | Cross-tenant data leaks | | Internal Errors | `TM-INT` | Panics, error message information leaks | | Git | `TM-GIT` | Repo access control, remote URL injection | | Logging | `TM-LOG` | Sensitive data in logs, log injection | | Python Sandbox | `TM-PY` | Monty resource limits, VFS bridge escapes | | Unicode | `TM-UNI` | Byte-boundary panics, homoglyph attacks | The full threat model, including mitigation status for each threat, is published in the rustdoc: [**bashkit::threat_model**](https://docs.rs/bashkit/latest/bashkit/threat_model/index.html). ## POSIX deviations for security Bashkit intentionally deviates from POSIX where compliance would compromise the sandbox. Key exclusions: - **`exec`**: would break sandbox containment (`TM-ESC-005`) - **`trap`**: handles shell events and self-sent virtual signals. Nested handlers share the function/source/child-shell depth budget (hard ceiling 16) plus a dedicated trap-nesting cap of 3 that trips first on small stacks; recursive signal delivery past either limit is skipped with a diagnostic and the first trip fails the exec with a resource-limit error - **Real process spawning**: all subprocess commands stay within the virtual interpreter (`TM-ESC-015`) These decisions are documented in [`knowledge/operations/limitations.md`](../knowledge/operations/limitations.md). ## Security testing Bashkit uses multiple layers of security testing: **Threat model tests**: 232 tests in `threat_model_tests.rs` that directly validate mitigations against documented threat IDs. Each test maps to a specific `TM-*` threat. **Fail-point injection**: A framework defined in [`knowledge/security/security-testing.md`](../knowledge/security/security-testing.md) that injects failures at specific points to verify the interpreter handles them safely. 14+ tests in `security_failpoint_tests.rs`. **Network security tests**: 68 tests covering allowlist enforcement, URL validation, timeout behaviour, and response limits. **Error handling tests**: 39 tests verifying that builtins wrapped with `catch_unwind` never leak panic messages, stack traces, or memory addresses. **Logging security tests**: 26 tests confirming that sensitive data (passwords, tokens, API keys, JWTs) is redacted in logs and that log injection is prevented. **Fuzz testing**: Parser and lexer fuzzing to catch panics and unexpected behaviour on malformed input. **Differential tests**: Compare Bashkit output against real Bash to ensure behaviour parity where expected, and confirm intentional divergences where security requires it. Arithmetic limits cover textual dollar expansion inside expressions. Nested array subscripts share the enclosing expression's recursion and work limits, including quoted arguments to `let`. Exceeding either limit returns an arithmetic error; subsequent executions can reuse the session. Expanded arithmetic expressions and associative-array keys are capped at 64 KiB. Their buffers reserve capacity against `max_live_intermediate_bytes` before allocation, including temporary copies of variable values. Each append charges `max_work_units` and checks cancellation and the execution deadline. Shared-budget exhaustion returns a resource-limit error rather than continuing the script; subsequent executions start with a fresh request budget. ## Panic safety All builtin commands are wrapped with `catch_unwind`. If a builtin panics, the error is caught and converted to a sanitised error message, no stack traces, no memory addresses, no real filesystem paths leak to the caller (`TM-INT-001`, `TM-INT-002`). ## Reporting security issues **Do not open a public GitHub issue for security vulnerabilities.** Email: **security@everruns.com** Please include a description of the vulnerability, steps to reproduce, and potential impact. We acknowledge reports within 48 hours, provide an initial assessment within 7 days, and target 30-day resolution for critical issues. See [`SECURITY.md`](../SECURITY.md) for the full policy. We appreciate responsible disclosure and acknowledge researchers who report valid vulnerabilities (with permission). --- # Networking & HTTP Source: https://bashkit.sh/docs/networking.md Bashkit's HTTP builtins, `curl`, `wget`, and `http`, are the only way a script can reach the network, and they are **default-deny**. With no configuration, every outbound request is blocked. You opt in host by host with a `NetworkAllowlist`. There is no DNS rebinding window, no automatic redirect following across hosts, and no access to private or cloud-metadata IP ranges. Networking is a sandbox boundary, not a convenience. curl / wget http allowlist + private-IP block sign (opt-in) bot-auth send → blocked → request fails ## Allowing hosts ```rust,no_run use bashkit::{Bash, NetworkAllowlist}; # #[tokio::main] # async fn main() -> bashkit::Result<()> { let allowlist = NetworkAllowlist::new() .allow("https://api.example.com") // entire host .allow("https://cdn.example.com/assets/"); // path prefix let mut bash = Bash::builder().network(allowlist).build(); bash.exec("curl https://api.example.com/v1/users").await?; # Ok(()) # } ``` ### Pattern matching A request matches an allowlist entry when: - **Scheme** matches exactly, `https` is not `http`. - **Host** matches exactly, no wildcards, no implicit subdomains. - **Port** matches, defaults applied (443 for https, 80 for http). - **Path** is a prefix, the entry's path must be a prefix of the request path. This is literal-string matching by design: there is no DNS resolution at check time, which closes the DNS-spoofing and rebinding classes of attack (TM-NET-001/002). ### Built-in SSRF protection Even for an allowed host, requests that resolve to private or reserved IP ranges are refused at connect time (`127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `169.254.0.0/16` including cloud metadata, CGNAT, and the IPv6 equivalents). This blocks SSRF via DNS rebinding even if an allowlisted hostname later resolves to an internal address (TM-NET-002/004/008). Override only when you fully control the environment: ```rust,no_run # use bashkit::NetworkAllowlist; let allowlist = NetworkAllowlist::new() .allow("http://localhost:8080") .block_private_ips(false); // dangerous — testing only ``` `NetworkAllowlist::allow_all()` disables host checks entirely. Use it only for fully trusted scripts. ## curl data options Repeated `-d`/`--data`, `--data-raw`, `--data-binary`, and `--data-urlencode` values are joined with `&` in command-line order. `-d @file` removes CR/LF bytes, `--data-binary @file` preserves the file exactly, `--data-raw` treats a leading `@` literally, and `--data-urlencode` supports both `@file` and `name@file`. Data requests default to `application/x-www-form-urlencoded` unless a content type was supplied. ```bash curl -d 'page=1' --data-urlencode 'query=hello world' https://api.example.com/search curl -G -d 'page=1' --data-urlencode 'query=hello world' https://api.example.com/search ``` The second form sends a GET and appends the ordered data to the existing query. The aggregate data is capped at 10 MB before dispatch; the URL allowlist still applies to the final request. ## CLI The CLI keeps network access off unless you ask for it: ```bash # Blocked by default bashkit -c 'curl https://example.com' # Unrestricted outbound (trusted scripts only) bashkit --http-allow-all -c 'curl https://example.com' ``` Per-host allowlisting is a library-level concern (`NetworkAllowlist`); the CLI exposes the coarse `--http-allow-all` switch for trusted use. ## Observing and rewriting requests HTTP requests flow through the same [hooks](../crates/bashkit/docs/hooks.md) pipeline as the rest of the interpreter, so a host can observe, rewrite, or cancel an outbound request before it leaves, useful for logging, header injection, or policy enforcement. ## See also - [Credential injection](../crates/bashkit/docs/credential-injection.md), attach secrets to outbound requests without exposing them to the script. - [Request signing](request-signing.md), cryptographic bot identity for signed outbound requests. - [Security](security.md), the full sandbox boundary model. --- # Credential injection Source: https://bashkit.sh/docs/credential-injection.md Bashkit supports transparent credential injection for outbound HTTP requests. Secrets are injected at the transport layer, sandboxed scripts never see the real credentials, preventing exfiltration. **See also:** - [Threat Model](./threat-model.md) - Security properties - [Custom Builtins](./custom_builtins.md) - Extending the shell - [Credential Injection Spec](https://github.com/everruns/bashkit/blob/main/knowledge/security/credential-injection.md) - Design decisions ## Two Modes ### Mode 1: Injection (recommended) The script has **no knowledge** of credentials. It makes plain requests; bashkit adds authentication headers automatically based on the URL. ```rust,ignore use bashkit::{Bash, Credential, NetworkAllowlist}; let mut bash = Bash::builder() .network(NetworkAllowlist::new() .allow("https://api.github.com")) .credential("https://api.github.com", Credential::bearer("ghp_xxxx")) .build(); let result = bash.exec("curl -s https://api.github.com/repos/foo/bar").await?; // Authorization: Bearer ghp_xxxx was added transparently. // The script never referenced any token. ``` ### Mode 2: Placeholder The script sees an **opaque placeholder** string in an environment variable. It uses the placeholder like a real API key. Bashkit replaces the placeholder with the real credential in outbound HTTP headers. ```rust,ignore use bashkit::{Bash, Credential, NetworkAllowlist}; let mut bash = Bash::builder() .network(NetworkAllowlist::new() .allow("https://api.openai.com")) .credential_placeholder("OPENAI_API_KEY", "https://api.openai.com", Credential::bearer("sk-real-key")) .build(); // Inside the sandbox, $OPENAI_API_KEY = "bk_placeholder_a8f3c9e1..." let result = bash.exec(r#" curl -H "Authorization: Bearer $OPENAI_API_KEY" \ https://api.openai.com/v1/chat/completions \ -d '{"model":"gpt-4","messages":[{"role":"user","content":"hi"}]}' "#).await?; // The placeholder was replaced with "sk-real-key" in the Authorization header. ``` **When to use placeholder mode:** - Agent-generated scripts that read env vars (e.g., `$OPENAI_API_KEY`) - SDKs that require a non-empty API key to initialize - Compatibility with existing code patterns ## Credential Types ```rust,no_run use bashkit::Credential; // Bearer token → Authorization: Bearer let cred = Credential::bearer("ghp_xxxx"); // Custom header let cred = Credential::header("X-Api-Key", "secret123"); // Multiple headers let cred = Credential::headers(vec![ ("X-Api-Key".into(), "key".into()), ("X-Api-Secret".into(), "secret".into()), ]); ``` ## URL Pattern Matching Credential patterns use the same matching rules as [`NetworkAllowlist`]: - **Scheme**: Must match exactly (`https` vs `http`) - **Host**: Must match exactly (no wildcards) - **Port**: Must match (defaults: 443 for HTTPS, 80 for HTTP) - **Path**: Pattern path is treated as a prefix ```rust,no_run use bashkit::{Bash, Credential, NetworkAllowlist}; let bash = Bash::builder() .network(NetworkAllowlist::new() .allow("https://api.example.com")) // Only inject for /v1/ paths .credential("https://api.example.com/v1/", Credential::bearer("v1_token")) // Different token for /v2/ .credential("https://api.example.com/v2/", Credential::bearer("v2_token")) .build(); ``` ## Security Properties ### What the script cannot do | Attack | Why it fails | |--------|-------------| | Read the real secret from env vars | Injection mode: no env var exists. Placeholder mode: env var contains random placeholder | | Exfiltrate placeholder to `evil.com` | [`NetworkAllowlist`] blocks unapproved hosts. Placeholder only replaced for matching URL patterns | | Set a fake `Authorization` header | Injected headers **overwrite** existing headers with the same name | | Log the credential via `echo` | Script only has access to the placeholder string, not the real secret | ### Header overwrite When injecting credentials, bashkit **removes** any existing headers with the same name before adding the credential header. This prevents a script from setting `Authorization: Basic evil` and having it forwarded alongside the injected `Authorization: Bearer real`. ### Non-blocking failures If credential injection fails (e.g., callback error), the request is sent **without** credentials. This follows the same principle as bot-auth signing (request-signing spec): tool availability is never sacrificed for authentication. ## Multiple Credentials You can configure credentials for multiple hosts: ```rust,no_run use bashkit::{Bash, Credential, NetworkAllowlist}; let bash = Bash::builder() .network(NetworkAllowlist::new() .allow("https://api.github.com") .allow("https://api.openai.com") .allow("https://registry.npmjs.org")) .credential("https://api.github.com", Credential::bearer("ghp_xxxx")) .credential("https://api.openai.com", Credential::bearer("sk-xxxx")) .credential("https://registry.npmjs.org", Credential::header("Authorization", "Bearer npm_xxxx")) .build(); ``` ## Mixing Modes Injection and placeholder modes can be used together: ```rust,no_run use bashkit::{Bash, Credential, NetworkAllowlist}; let bash = Bash::builder() .network(NetworkAllowlist::new() .allow("https://api.github.com") .allow("https://api.openai.com")) // GitHub: pure injection (script doesn't know about auth) .credential("https://api.github.com", Credential::bearer("ghp_xxxx")) // OpenAI: placeholder (script uses $OPENAI_API_KEY) .credential_placeholder("OPENAI_API_KEY", "https://api.openai.com", Credential::bearer("sk-xxxx")) .build(); ``` ## How It Works Credential injection is built on the [`hooks`] system. At build time, `BashBuilder` converts credential rules into a `before_http` interceptor hook. The hook fires after the URL allowlist check but before the request is sent: ```text 1. Allowlist check (security gate) 2. Private IP / SSRF check (SSRF protection) 3. before_http hooks (credential injection happens here) 4. Bot-auth signing (Ed25519 signatures, if configured) 5. Request sent ``` This means: - Credentials are only injected for **allowed** URLs - Credentials are never sent to **private IPs** - Credentials compose with **bot-auth** signing - Custom `before_http` hooks run alongside credential injection --- # Request signing Source: https://bashkit.sh/docs/request-signing.md Bot identity on the web has historically been an honor system. A `User-Agent` string is trivially spoofable, nothing stops a scraper from claiming to be Googlebot, and IP allowlists fall apart the moment your agent runs on ephemeral cloud addresses. Servers are left inferring identity from traffic patterns instead of verifying it. Request signing replaces that guesswork with cryptography. Behind the `bot-auth` feature, Bashkit transparently signs **every** outbound HTTP request with an Ed25519 signature per [RFC 9421](https://www.rfc-editor.org/rfc/rfc9421) (web-bot-auth profile). A server that trusts your agent's public key can verify, not assume, who is calling, unlocking per-key rate limits, selective API access, and real audit trails. > Background: [Request Signing, Cryptographic Identity for AI Agents](https://medium.com/everruns/request-signing-cryptographic-identity-for-ai-agents-0e5fc1b52aa3). Bashkit agent private seed (zeroized) target server verifies signature signed request + Signature-Agent: agent.example well-known key directory JWK Thumbprint → public key Signature · Signature-Input · Signature-Agent ## How it works Signing happens inside `HttpClient`, at the same layer as the [network allowlist](networking.md) check, so it covers `curl`, `wget`, `http`, per-request timeouts, custom handlers, and each hop of a manually-followed redirect. No script can bypass it, and there are no CLI flags or script changes to make: it is transparent. It is also **non-blocking**. If signing ever fails (clock skew, key issue), the request is sent unsigned rather than dropped, tool availability is never sacrificed for signing (TM-AVAIL-001). ## Configuration ```rust,no_run use bashkit::{Bash, BotAuthConfig}; # fn main() -> bashkit::Result<()> { let seed = std::env::var("AGENT_SIGNING_SEED").unwrap(); let config = BotAuthConfig::from_base64_seed(&seed)? .with_agent_fqdn("agent.example.com") // emitted as Signature-Agent .with_validity_secs(300); // signature lifetime (default 300) let mut bash = Bash::builder().bot_auth(config).build(); # let _ = bash; # Ok(()) # } ``` The signing key never leaves `BotAuthConfig`; only the public key is derivable. The seed bytes are zeroized on drop (TM-CRY-001). ## Serving your public key A verifying server needs your public key. Derive it (without ever exposing the seed) and publish it at your well-known key directory endpoint: ```rust,no_run use bashkit::derive_bot_auth_public_key; # fn main() -> bashkit::Result<()> { # let seed = [0u8; 32]; let public = derive_bot_auth_public_key(&seed)?; println!("key id: {}", public.key_id); // JWK Thumbprint (RFC 7638) // public.jwk is the JSON Web Key to serve from your directory # Ok(()) # } ``` The `keyid` in the signature is the JWK Thumbprint, so a server can look up the exact key that signed a request. ## What gets added Per RFC 9421 with the `web-bot-auth` tag, signing covers `@method` and `@target-uri` (plus `signature-agent` when an FQDN is set), with a fresh 32-byte nonce and `created` / `expires` timestamps on every request: | Header | Value | |--------|-------| | `Signature` | `sig=::` | | `Signature-Input` | `sig=("@method" "@target-uri");created=…;expires=…;keyid="…";alg="ed25519";nonce="…";tag="web-bot-auth"` | | `Signature-Agent` | your FQDN (only when set) | The nonce defends against replay; the expiry window bounds signature validity. ## See also - [Networking & HTTP](networking.md), the allowlist that gates every request. - [Credential injection](../crates/bashkit/docs/credential-injection.md), attach bearer tokens without exposing them to scripts. - Spec: [`knowledge/security/request-signing.md`](https://github.com/everruns/bashkit/blob/main/knowledge/security/request-signing.md). - [RFC 9421](https://www.rfc-editor.org/rfc/rfc9421) · [RFC 7638](https://www.rfc-editor.org/rfc/rfc7638) · [web-bot-auth architecture](https://datatracker.ietf.org/doc/html/draft-meunier-web-bot-auth-architecture). --- # Python builtin Source: https://bashkit.sh/docs/python.md > **Experimental.** Monty is an early-stage Python interpreter that may have > undiscovered crash or security bugs. Resource limits are enforced by Monty's > runtime. The integration should be treated as experimental. Bashkit embeds the [Monty](https://github.com/pydantic/monty) Python interpreter, a pure-Rust implementation of Python 3.12. Python runs entirely in-memory with configurable resource limits and no host access. **See also:** - [Embedded CPython (WebAssembly)](./cpython.md) - Full CPython 3.14 in a wasm sandbox (`cpython` feature) - [Threat Model](./threat-model.md) - Security considerations (TM-PY-*) - [Custom Builtins](./custom_builtins.md) - Writing your own builtins - [Compatibility Reference](./compatibility.md) - Bash feature support - [`knowledge/runtimes/python-builtin.md`][spec] - Full specification ## Quick Start Enable the `python` feature and register via builder: ```rust use bashkit::Bash; # #[tokio::main] # async fn main() -> bashkit::Result<()> { let mut bash = Bash::builder() .python() .env("BASHKIT_ALLOW_INPROCESS_PYTHON", "1") .build(); let result = bash.exec("python3 -c \"print('hello from Monty')\"").await?; assert_eq!(result.stdout, "hello from Monty\n"); # Ok(()) # } ``` ## Usage Patterns ### Shared Tool Registry With the [`bashkit-scripted-tool`](https://docs.rs/bashkit-scripted-tool) crate (feature `python`), one `ToolRegistry` can expose the same definitions and callbacks as shell commands and Python functions: ```rust,ignore use bashkit::Bash; use bashkit_scripted_tool::{ToolArgs, ToolDef, ToolRegistry}; let registry = ToolRegistry::builder() .tool_fn(ToolDef::new("orders.list", "List orders"), |_args: &ToolArgs| { Ok(r#"[{"id": 1}]"#.to_string()) }) .build(); let bash = registry.install(Bash::builder()).build(); ``` ```python orders = tools.orders.list({"customer": "acme"}) matches = tools.discover({"category": "orders"}) ``` Schema validation, policy, deadlines, sanitized errors, callbacks, and tracing are shared with shell and TypeScript calls. Python execution still requires the normal `BASHKIT_ALLOW_INPROCESS_PYTHON=1` operator opt-in. ### Inline Code ```bash python3 -c "print(2 ** 10)" # Output: 1024 ``` ### Expression Evaluation When no `print()` is called, the last expression is displayed (REPL behavior): ```bash python3 -c "2 + 2" # Output: 4 ``` ### Script Files (from VFS) ```bash cat > /tmp/script.py << 'EOF' data = [1, 2, 3, 4, 5] print(f"sum={sum(data)}, avg={sum(data)/len(data)}") EOF python3 /tmp/script.py ``` ### Pipelines and Command Substitution ```bash result=$(python3 -c "print(42 * 3)") echo "Result: $result" echo "print('piped')" | python3 ``` ## Virtual Filesystem (VFS) Bridging Python `open()` and `pathlib.Path` operations are bridged to Bashkit's virtual filesystem. Files created by bash are readable from Python and vice versa. ### Bash → Python ```bash echo "important data" > /tmp/shared.txt python3 -c " content = open('/tmp/shared.txt').read() print(f'Got: {content.strip()}') " ``` ### Python → Bash ```bash python3 -c " with open('/tmp/result.txt', 'w') as f: _ = f.write('computed by python\n') " cat /tmp/result.txt ``` ### Supported File Operations | Operation | Example | |-----------|---------| | Open/read | `open('f.txt').read()` | | Open/write | `open('f.txt', 'w').write('data')` | | Open/append | `open('f.txt', 'a').write('more')` | | Path open | `Path('f.txt').open('r')` | | Read text | `Path('f.txt').read_text()` | | Read bytes | `Path('f.txt').read_bytes()` | | Write text | `Path('f.txt').write_text('data')` | | Write bytes | `Path('f.txt').write_bytes(b'data')` | | Exists | `Path('f.txt').exists()` | | Is file/dir | `Path('f.txt').is_file()`, `.is_dir()` | | Mkdir | `Path('d').mkdir(parents=True, exist_ok=True)` | | Delete | `Path('f.txt').unlink()` | | List dir | `Path('.').iterdir()` | | Stat | `Path('f.txt').stat().st_size` | | Rename | `Path('old').rename('new')` | | Env vars | `os.getenv('KEY')`, `os.environ` | ### Architecture ```text Python code → Monty VM → OsCall(Open/ReadText, path) → Bashkit VFS → resume ``` Monty pauses at filesystem operations, Bashkit bridges them to the VFS, then resumes execution with the result (or a Python exception like `FileNotFoundError`). ## Resource Limits Default limits prevent runaway Python code. Customize via `PythonLimits`: ```rust,no_run use bashkit::{Bash, PythonLimits}; use std::time::Duration; # fn main() { let bash = Bash::builder() .python_with_limits( PythonLimits::default() .max_duration(Duration::from_secs(5)) .max_memory(16 * 1024 * 1024) // 16 MB .max_recursion(50) ) .build(); # } ``` | Limit | Default | Purpose | |-------|---------|---------| | Duration | 30 seconds | Execution timeout | | Memory | 64 MB | Heap memory cap, also caps collected `print` output | | Recursion | 200 | Call stack depth | ## LLM Tool Integration When using `BashTool` for AI agents, call `.python()` on the tool builder: ```rust,no_run use bashkit::{BashTool, Tool}; # fn main() { let tool = BashTool::builder() .python() .build(); // help() and system_prompt() automatically document Python limitations let help = tool.help(); // Includes a Markdown Notes section with Python hints # } ``` The builtin's `llm_hint()` is automatically included in the tool's documentation, so LLMs know file I/O is VFS-scoped and HTTP requests/classes are unavailable. `BashTool::builder().python_with_external_handler(limits, names, handler)` exposes the same named callbacks through the Rust tool builder. The Python runtime gate still applies. Foreign bindings can use `bashkit::runtime_value` for bounded JSON conversion; native Rust callbacks keep their Monty value types. ## Limitations **VFS-only file I/O.** `open()` and `pathlib.Path` read/write Bashkit's virtual filesystem, not the host filesystem: ```python from pathlib import Path with open('/tmp/data.txt', 'w') as f: f.write('hello') content = Path('/tmp/data.txt').read_text() ``` **No HTTP/network.** No `socket`, `urllib`, `requests`, or `http.client` modules. Monty has no network primitives and no OsCall variants for network operations. **No classes.** Class definitions are not yet supported by Monty (planned upstream). **No third-party imports.** Only builtin modules (`sys`, `typing`, `os`, `pathlib`, `math`, `json`, `datetime`) are available. No `pip install`, no `import numpy`. **No `re` module.** Disabled due to catastrophic backtracking DoS risk in untrusted code execution. **No `str.format()`.** Use f-strings instead: `f"value={x}"` not `"value={}".format(x)`. ## Security All Python execution runs in a virtual environment: - **No host filesystem access**: all paths resolve through the VFS - **No network access**: no sockets, HTTP, or DNS - **No process spawning**: no `os.system()`, `subprocess`, or `__import__('os')` - **Resource limited**: time, memory (including collected output), and recursion caps - **Path traversal safe**: `../..` is resolved by VFS path normalization See threat IDs TM-PY-001 through TM-PY-029 in the [threat model](./threat-model.md). [spec]: https://github.com/everruns/bashkit/blob/main/knowledge/runtimes/python-builtin.md --- # CPython builtin Source: https://bashkit.sh/docs/cpython.md Bashkit can run real CPython 3.14 as `python`/`python3`. The interpreter is compiled to WebAssembly (`wasm32-wasip1`), pre-initialized into a snapshot and shipped inside the binary, so there is nothing to install and no network access at build or run time. Each `python3` call runs in a fresh, isolated instance that can only reach the Bashkit virtual filesystem and its own stdio. **See also:** - [Embedded Python (Monty)](./python.md) - The lighter, Rust-native alternative - [Threat Model](./threat-model.md) - Security considerations (TM-PY-CPY-*) - [Compatibility Reference](./compatibility.md) - Bash feature support - [`knowledge/runtimes/cpython-wasm.md`][spec] - Design, measurements and decisions ## Quick start Enable the `cpython` cargo feature and register the builtins: ```toml [dependencies] bashkit = { version = "0.20.1", features = ["cpython"] } ``` ```rust use bashkit::Bash; # #[tokio::main] # async fn main() -> bashkit::Result<()> { let mut bash = Bash::builder().cpython().build(); let r = bash .exec("python3 -c 'import json, sys; print(json.dumps({\"v\": sys.version_info[:2]}))'") .await?; assert_eq!(r.stdout, "{\"v\": [3, 14]}\n"); # Ok(()) # } ``` No runtime opt-in variable is needed (unlike Monty's `BASHKIT_ALLOW_INPROCESS_PYTHON`): calling `.cpython()` is the opt-in, because the guest never runs native code in your process. The interpreter loads on the first call of a process: the first `python3` takes about 19 ms on the reference machine, later calls about 4.4 ms. To move that one-time cost out of the first request, call `bashkit::warm_up()` at startup. On hosts where the linker or loader cannot preserve 64 KiB module alignment, such as macOS, an aligned immutable copy is cached once for the process; later calls reuse it. ## Why CPython instead of Monty [Monty](./python.md) is a Python *subset* written in Rust and designed for code-mode execution: evaluate a snippet, call back into host functions, return a value. Scripts written by people and agents expect a Python *command line* and the standard library behind it. CPython provides that: | | Monty (`python` feature) | CPython (`cpython` feature) | |---|---|---| | Language | Subset (no classes, limited stdlib) | Full Python 3.14 | | Stdlib | `math`, `pathlib`, `os.getenv`, `sys`, `typing`, ... | Pure-Python stdlib for scripts (see [Limitations](#limitations)) plus `json`, `re`, `csv`, `sqlite3`, `zlib`, `hashlib`, `decimal`, `datetime`, `asyncio`, ... | | CLI | `-c`, file, `-` | `-c`, `-m`, file, directory with `__main__.py`, `-`, stdin, `-x`, `-W`, `-V`, `-h` | | Errors | Monty-specific text | CPython tracebacks, exit codes, `sys.exit` semantics | | Isolation | In-process Rust interpreter | WebAssembly sandbox (memory-safe boundary), fresh instance per call | | Start per call | ~15 µs | ~4.4 ms (first call in a process ~19 ms) | | CPU-bound speed | Native | ~4-30x slower than Monty (interpreted wasm); ~4x slower with `cpython-native` | | Host callbacks | Yes (external functions) | Not yet | | HTTP | No | `urllib.request`, `http.client` through Bashkit's egress allowlist | Pick CPython when scripts need real Python behavior; pick Monty when you need host function callbacks or microsecond start-up for tiny snippets. Both can be compiled in: the builder method called last owns `python`/`python3`. ## What works - `python3 -c CODE`, `python3 FILE`, `python3 -m MODULE`, `python3 DIR` (runs `DIR/__main__.py`), `python3 -` and programs piped on stdin. - `sys.argv`, `sys.exit`, uncaught exceptions (exit 1, traceback on stderr), `os._exit`, `atexit`, `input()`, binary stdio via `sys.stdout.buffer`. - Exported shell variables in `os.environ`; `PWD` becomes the working directory, so relative paths resolve like in a real shell. - `open()`, `os`, `pathlib`, `shutil`, `glob`, `tempfile`, `sqlite3` database files, `gzip`/`zipfile`/`tarfile` on the virtual filesystem. Files the script leaves open are flushed when it exits. - Local modules next to the script or in the working directory import as usual. - `asyncio` (single-threaded event loop, timers, queues, gather). ## Limits ```rust use bashkit::{Bash, CPythonLimits}; use std::time::Duration; let bash = Bash::builder() .cpython_with_limits( CPythonLimits::default() .max_duration(Duration::from_secs(5)) // wall clock per call (default 30 s) .max_memory(128 * 1024 * 1024) // guest memory (default 256 MB, max 1 GB) .max_recursion(500) // sys.getrecursionlimit() (default 1000) .max_output(1024 * 1024), // stdout + stderr bytes (default 16 MB) ) .build(); ``` - **Time**: a call past its deadline stops with exit code 124 and `python3: execution timed out ...`. A tighter Bashkit `ExecutionLimits` timeout or cancellation wins. - **Memory**: allocation past the cap raises `MemoryError` inside Python. - **Output**: bytes past the cap are dropped and stderr ends with `python3: output truncated at N bytes`. - **Files**: Bashkit filesystem limits (file size, total bytes, file count) apply; violations raise `OSError`. - **Concurrency**: up to 512 CPython calls run at once per process; further calls wait for a free slot within their own deadline. ## HTTP With the `http_client` feature and a network allowlist (`BashBuilder::network`), `urllib.request` and `http.client` work for `http://` and `https://` URLs. Each request is handed to the host and goes through the same pipeline as `curl`: allowlist, private-IP (SSRF) checks, credential injection, request signing, your `HttpTransport` and the response size cap. TLS runs on the host; the guest has no sockets. ```rust,ignore let mut bash = Bash::builder() .cpython() .network(NetworkAllowlist::new().allow("https://api.example.com")) .build(); bash.exec(r#"python3 -c ' import json, urllib.request with urllib.request.urlopen("https://api.example.com/v1/items") as r: print(json.load(r)) '"#).await?; ``` - A denied URL raises `URLError` (or `ConnectionError` from `http.client`) with the same "access denied" text `curl` prints; a timeout raises `TimeoutError`. Without a network allowlist every request fails with "network access not configured". - Redirects are followed by Python, so every hop is checked again. - Methods: GET, POST, PUT, DELETE, HEAD, PATCH. Headers the host owns (`Host`, `Content-Length`, `Transfer-Encoding`, `Connection`, proxy headers) are set by the host; values with CR/LF are rejected. - Responses are buffered (up to the client's `max_response_bytes`), so streaming past that cap fails. `ssl` contexts, `verify=False`, client certificates and proxy settings are ignored: the host verifies TLS. - `CPythonLimits::max_http_requests` caps requests per call (default 100); a request's timeout never outlasts the call's deadline. ### `requests` and `httpx` `import requests`, `import httpx` and `import httpx2` work out of the box. They are Bashkit's own compact implementations of the common API, written on top of the same host bridge (not the upstream packages, which take seconds to import in the sandbox). They are preloaded in the snapshot, so importing them costs nothing. ```python import requests r = requests.get("https://api.example.com/v1/items", params={"page": 2}, timeout=5) r.raise_for_status() print(r.json()) import httpx with httpx.Client(base_url="https://api.example.com", headers={"X-Key": "..."}) as c: print(c.post("/v1/items", json={"name": "a"}).status_code) ``` - **requests**: `get`/`post`/`put`/`patch`/`delete`/`head`/`request`, `Session` (headers, params, auth, cookies, hooks), `params`, `data`, `json`, `files` (multipart), `headers`, `cookies`, basic `auth`, `timeout`, `allow_redirects`; `Response` with `status_code`, `ok`, `reason`, `headers`, `content`, `text`, `json()`, `url`, `history`, `links`, `iter_content`/`iter_lines`, `raise_for_status()`; the upstream exception classes (`ConnectionError`, `ReadTimeout`, `HTTPError`, ...). - **httpx** (and `httpx2`, the same module): the verb functions and `stream()`, `Client` and `AsyncClient` (`base_url`, `headers`, `params`, `cookies`, `auth`, `timeout`, `follow_redirects`, `event_hooks`, `transport=httpx.MockTransport(...)`), `Response`, `URL`, `Headers`, `QueryParams`, `Cookies`, `Timeout`, `BasicAuth`, `codes` and the upstream exception classes. As in httpx, redirects are not followed unless `follow_redirects=True`. - Not supported: retries (`HTTPAdapter(max_retries=...)` is accepted and ignored), proxies, client certificates, HTTP/2, `OPTIONS` (not an allowed method), digest auth, streaming uploads. Bodies are buffered, so `stream=True` and `iter_*` walk a body that is already complete. `AsyncClient` requests run one at a time. ## Limitations - **No subprocesses**: `subprocess`, `os.system`, `os.fork`, `os.popen` raise `OSError`/`AttributeError`. Python cannot call back into the shell yet. - **HTTP only, through Bashkit's egress**: see [HTTP](#http). Raw `socket` connections, `ssl` and non-HTTP protocols are unavailable. - **No threads**: `threading.Thread.start()` raises `RuntimeError`; `multiprocessing` and `concurrent.futures.ProcessPoolExecutor` are absent. `asyncio` works. - **No native extensions or pip**: only the bundled stdlib (plus Bashkit's own `requests`/`httpx`, see [HTTP](#http)). `ctypes`, `numpy` and other third-party packages are unavailable; `ssl`, `_hashlib` (OpenSSL), `tkinter`, `curses`, `readline`, `dbm.gnu` are not built. `hashlib` still provides md5, sha1, sha2, sha3 and blake2. - **No interactive mode**: `python3` with no program reads one from stdin; there is no REPL, `pydoc` (`help()`) is not shipped, and `breakpoint()` prints a notice and continues (no debugger). - **Stdlib is bytecode only**: tracebacks through stdlib code show no source line, and `inspect.getsource()` fails on stdlib objects. Your own code keeps full tracebacks. Non-HTTP network clients and servers (`smtplib`, `ftplib`, `http.server`, `xmlrpc`, ...) are not shipped since the guest has no sockets. - **Trimmed for scripts**: the stdlib targets agents running file-processing and glue scripts. Test, profiling and packaging tools (`unittest`, `doctest`, `cProfile`, `profile`, `trace`, `compileall`, `zipapp`, ...), `dbm`/`shelve`, `plistlib`, `wave`, `netrc`, `cmd`, `tty`/`pty`, and `bz2`/`lzma`/`compression.zstd` (no C codec in the guest) are not shipped. `gzip`, `zipfile` (deflate) and `tarfile` (plain or gzip) work. - **Symlinks are not followed**, like everywhere in the Bashkit VFS. - **`errno` numbers are WASI's** (`ENOENT` is 44, not 2). Exception types (`FileNotFoundError`, ...) and messages are correct; code comparing `e.errno == errno.ENOENT` works because the `errno` module matches. - **Fixed hash seed**: `hash()` of `str`/`bytes` is the same in every call (the seed is baked into the snapshot). `random` is re-seeded per call (on first use). - **Deep C-level recursion** (for example `repr` of a list nested 100 000 levels) ends the call with `python3: fatal error: stack overflow in the interpreter` instead of `RecursionError`. - **CPU-bound code is slow**: the guest runs on Wasmtime's portable Pulley interpreter, roughly 4-30x slower than Monty and far slower than native CPython. Start-up, not throughput, is what this runtime is tuned for. - **No host callbacks** (Monty's external functions) or `ToolDef` integration yet. - Interpreter-start options (`-E`, `-I`, `-s`, `-S`, `-B`, `-u`, `-O`, `-q`, `-X ...`) are accepted and ignored. ## Native code (opt-in) By default the interpreter ships as portable Pulley bytecode, which needs no executable memory. Enable `cpython-native` instead of `cpython` to compile it to machine code for your target at build time: ```toml bashkit = { version = "0.20.1", features = ["cpython-native"] } ``` Calls get 4-10x faster (`print(1)` ~1 ms instead of ~4 ms; CPU-bound code ~10x). In exchange the host must allow executable memory, and the first load in a process takes ~47 ms, so call `bashkit::warm_up()` at startup. Nothing is compiled at run time with either option. ## Binary size The `cpython` feature adds about 45 MB to a binary: the precompiled interpreter snapshot (~41 MB, mostly the pre-initialized 40 MB heap image so it can be mapped copy-on-write) and the zipped stdlib bytecode (~3.2 MB) are embedded, plus the Wasmtime runtime. Pages are mapped on demand, so resident memory per process is far smaller. [spec]: https://github.com/everruns/bashkit/blob/main/knowledge/runtimes/cpython-wasm.md --- # TypeScript builtin Source: https://bashkit.sh/docs/builtin_typescript.md Bashkit includes an embedded TypeScript interpreter powered by [ZapCode](https://github.com/TheUncharted/zapcode), a pure-Rust TypeScript runtime with ~2µs cold start and zero V8 dependency. TypeScript runs entirely in-memory alongside bash, files written by bash are readable from TypeScript and vice versa. ## Getting started Add the `typescript` feature to your `Cargo.toml`: ```toml [dependencies] bashkit = { version = "0.20.1", features = ["typescript"] } ``` Enable TypeScript in the builder: ```rust use bashkit::Bash; let mut bash = Bash::builder().typescript().build(); // Run TypeScript inline let r = bash.exec("ts -c \"console.log('hello')\"").await?; assert_eq!(r.stdout, "hello\n"); ``` Or register the TypeScript extension explicitly: ```rust use bashkit::{Bash, TypeScriptExtension}; let mut bash = Bash::builder() .extension(TypeScriptExtension::default()) .build(); ``` ## Command aliases Five commands are registered, all running the same ZapCode interpreter: | Command | Inline flag | Example | |---------|-------------|---------| | `ts` | `-c` | `ts -c "console.log('hi')"` | | `typescript` | `-c` | `typescript -c "1 + 2"` | | `node` | `-e` | `node -e "console.log('hi')"` | | `deno` | `-e` | `deno -e "console.log('hi')"` | | `bun` | `-e` | `bun -e "console.log('hi')"` | Both `-c` and `-e` are accepted by all aliases. ## Running TypeScript files Write a `.ts` file to the VFS and execute it: ```bash cat > /tmp/hello.ts << 'EOF' const greet = (name: string): string => `Hello, ${name}!`; console.log(greet("world")); EOF ts /tmp/hello.ts ``` Output: `Hello, world!` Scripts can also be piped via stdin: ```bash echo "console.log(2 ** 10)" | ts ``` ## Working with the virtual filesystem TypeScript code can read and write files through async VFS functions that are automatically available as globals: ```bash # Write from bash echo "important data" > /tmp/config.txt # Read from TypeScript ts -c "await readFile('/tmp/config.txt')" # Output: important data # Write from TypeScript ts -c "await writeFile('/tmp/output.txt', 'computed result\n')" # Read from bash cat /tmp/output.txt # Output: computed result ``` ### Available VFS functions | Function | Description | |----------|-------------| | `readFile(path)` | Read file contents as string | | `writeFile(path, content)` | Write string to file | | `exists(path)` | Check if path exists (returns boolean) | | `readDir(path)` | List directory entries (returns string[]) | | `mkdir(path)` | Create directory (recursive) | | `remove(path)` | Delete file or directory | | `stat(path)` | Get file metadata (returns JSON string) | ## Example: data processing pipeline Bash and TypeScript can work together in a single session, each using their strengths: ```bash # Step 1: Bash generates raw data for i in $(seq 1 5); do echo "$i,$((i * 10)),$((RANDOM % 100))" >> /tmp/data.csv done # Step 2: TypeScript processes it ts -c " const csv = await readFile('/tmp/data.csv'); const rows = csv.trim().split('\n').map(r => r.split(',').map(Number)); const total = rows.reduce((sum, [_id, value, _score]) => sum + value, 0); await writeFile('/tmp/summary.txt', 'Total: ' + total + '\n'); " # Step 3: Bash uses the result cat /tmp/summary.txt # Output: Total: 150 ``` ## Example: JSON transformation ```bash # Create JSON with bash cat > /tmp/users.json << 'EOF' [ {"name": "Alice", "age": 30}, {"name": "Bob", "age": 25}, {"name": "Charlie", "age": 35} ] EOF # Transform with TypeScript ts -c " const data = JSON.parse(await readFile('/tmp/users.json')); const names = data.map(u => u.name).join(', '); console.log('Users: ' + names); console.log('Average age: ' + (data.reduce((s, u) => s + u.age, 0) / data.length)); " ``` ## Example: script file with VFS ```bash # Write a TypeScript utility script cat > /tmp/analyze.ts << 'TSEOF' const content = await readFile('/tmp/numbers.txt'); const nums = content.trim().split('\n').map(Number); const sum = nums.reduce((a, b) => a + b, 0); const avg = sum / nums.length; console.log('Count: ' + nums.length); console.log('Sum: ' + sum); console.log('Avg: ' + avg.toFixed(2)); console.log('Min: ' + Math.min(...nums)); console.log('Max: ' + Math.max(...nums)); TSEOF # Generate data and run the script seq 1 10 > /tmp/numbers.txt ts /tmp/analyze.ts ``` ## Configuration ### Resource limits ```rust use bashkit::{Bash, TypeScriptLimits}; use std::time::Duration; let bash = Bash::builder() .typescript_with_limits( TypeScriptLimits::default() .max_duration(Duration::from_secs(5)) .max_memory(16 * 1024 * 1024) // 16 MB .max_stack_depth(100) .max_allocations(100_000) ) .build(); ``` ### Disabling compat aliases If you only want `ts`/`typescript` and not `node`/`deno`/`bun`: ```rust use bashkit::{Bash, TypeScriptConfig}; let bash = Bash::builder() .typescript_with_config(TypeScriptConfig::default().compat_aliases(false)) .build(); // ts works: bash.exec("ts -c \"console.log('ok')\"").await?; // node does NOT work: let r = bash.exec("node -e \"console.log('ok')\"").await?; assert_ne!(r.exit_code, 0); ``` ### Disabling unsupported-mode hints By default, using Node/Deno/Bun-specific flags shows helpful guidance. Disable this for cleaner error output: ```rust use bashkit::{Bash, TypeScriptConfig}; let bash = Bash::builder() .typescript_with_config(TypeScriptConfig::default().unsupported_mode_hint(false)) .build(); ``` ## Supported TypeScript features | Feature | Example | |---------|---------| | Variables | `let x = 10; const y = 20;` | | Arrow functions | `const add = (a: number, b: number) => a + b;` | | Template literals | `` `hello ${name}` `` | | Destructuring | `const { x, y } = obj; const [a, ...rest] = arr;` | | Async/await | `const data = await readFile('/tmp/f.txt');` | | Array methods | `.map()`, `.filter()`, `.reduce()`, `.forEach()`, `.find()` | | For loops | `for`, `for...of`, `for...in`, `while`, `do...while` | | Conditionals | `if/else`, ternary, `switch/case` | | Type annotations | Parsed and accepted but not enforced at runtime | | Math | `Math.floor()`, `Math.min()`, `Math.max()`, `Math.round()` etc. | | JSON | `JSON.parse()`, `JSON.stringify()` | | Closures | Full lexical scoping with closure capture | | Generators | `function*` with `yield` | ## What's NOT supported | Feature | Reason | |---------|--------| | `import`/`require` | No module system | | `eval()`/`Function()` | Blocked for security | | `fetch`/`XMLHttpRequest` | No network access | | `process`/`Deno`/`Bun` globals | No runtime APIs | | npm packages | No package manager | | DOM APIs | No browser environment | ## Security TypeScript execution is fully sandboxed: - All file I/O goes through the virtual filesystem (no host access) - No network, no process spawning, no dynamic code evaluation - Independent resource limits (time, memory, stack, allocations) - Opt-in only: requires both `typescript` Cargo feature AND `.typescript()` builder call - Path traversal (`../../../etc/passwd`) is blocked by VFS normalization See [TM-TS threat entries](../knowledge/security/threat-model.md) for the full security analysis. --- # SQLite builtin Source: https://bashkit.sh/docs/sqlite.md Bashkit can embed a SQLite-compatible engine inside a sandbox by enabling the `sqlite` feature. The engine is [Turso](https://github.com/tursodatabase/turso) (`turso_core`), a pure-Rust rewrite of SQLite that exposes a pluggable `IO` trait, letting us bind it to bashkit's virtual filesystem. > ⚠️ Turso is **BETA** upstream. The feature is opt-in at the cargo > level *and* at runtime via `BASHKIT_ALLOW_INPROCESS_SQLITE=1`. Enable it > only for trusted scripts until upstream cuts a stable release. ## Quick start ```toml # Cargo.toml bashkit = { version = "0.20.1", features = ["sqlite"] } ``` ```rust,ignore use bashkit::Bash; #[tokio::main] async fn main() -> bashkit::Result<()> { let mut bash = Bash::builder() .sqlite() .env("BASHKIT_ALLOW_INPROCESS_SQLITE", "1") .build(); let r = bash .exec(r#"sqlite /tmp/notes.sqlite ' CREATE TABLE IF NOT EXISTS notes(id INTEGER PRIMARY KEY, body TEXT); INSERT INTO notes(body) VALUES (\"hello\"); '"#) .await?; println!("write: {}", r.stderr); let r = bash.exec(r#"sqlite -header /tmp/notes.sqlite "SELECT * FROM notes""#).await?; print!("{}", r.stdout); Ok(()) } ``` ## Two backends `SqliteBackend::Memory` (default) loads the database file from the VFS into turso's `MemoryIO`, runs the SQL, and flushes the resulting bytes back to the VFS at command boundary. `SqliteBackend::Vfs` plugs the bashkit `FileSystem` directly into turso via a custom `IO` impl; same observable semantics, different code path. ```rust,ignore use bashkit::{Bash, SqliteBackend, SqliteLimits}; let bash = Bash::builder() .sqlite_with_limits( SqliteLimits::default() .backend(SqliteBackend::Vfs) .max_db_bytes(8 * 1024 * 1024), ) .env("BASHKIT_ALLOW_INPROCESS_SQLITE", "1") .build(); ``` You can also flip the backend per-invocation with `-backend memory|vfs`. ## In-memory databases Use `:memory:` as the database argument when you don't need persistence: ```bash sqlite :memory: ' WITH RECURSIVE r(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM r WHERE n<10) SELECT sum(n) FROM r ' ``` ## Output modes | Flag | Mode | Notes | |--------------|-----------|----------------------------------------| | (default) | `list` | `|`-separated, one row per line | | `-csv` | `csv` | RFC 4180 quoting | | `-tabs` | `tabs` | Tab-separated | | `-line` | `line` | `name = value` blocks | | `-column` | `column` | Column-aligned with `--` separator | | `-box` | `box` | ASCII box-drawing borders | | `-json` | `json` | Array of objects via `serde_json` | | `-markdown` | `markdown`| GitHub-flavoured table | `.headers on` adds a header row; `-header` is its CLI equivalent. ## Dot-commands ```text .help Show command list .quit / .exit Stop execution .tables [PAT] List tables (LIKE pattern) .schema [PAT] Print CREATE statements .indexes [PAT] List indexes .headers on|off Toggle column headers .mode MODE Switch output mode .separator SEP Set field separator (escapes: \t \n \r \0) .nullvalue STR Set NULL placeholder .dump Dump schema + data as SQL .read PATH Execute SQL from a VFS file ``` Dot-commands must be on their own line. `.read` is bounded to 16 levels of nesting to prevent stack overflow on self-referential scripts. ## Resource limits `SqliteLimits` caps every resource the engine can use to push back against malicious or runaway SQL. Defaults: | Limit | Default | |------------------------|---------------| | `max_script_bytes` | 4 MiB | | `max_rows_per_query` | 1,000,000 | | `max_db_bytes` | 256 MiB | | `max_duration` | 30 s | | `max_statements` | 10,000 | | `MAX_DOT_READ_DEPTH` | 16 (constant) | `max_duration` is enforced via a wall-clock deadline shared across every statement in the invocation. The step loop checks the deadline on each turn and calls `Statement::interrupt()` once it expires. Pass `std::time::Duration::ZERO` to opt out (useful for CI hosts with bursty schedulers). Tune per workload via `SqliteLimits::default().max_*(...)`. ## Security See `knowledge/runtimes/sqlite-builtin.md` § "Trust Model & Threats" for the full threat table. Highlights: - **No host filesystem access.** All paths resolve through the bashkit VFS; even with `-backend vfs` and an absolute path like `/etc/passwd`, the engine reads from the VFS only. - **Default-disabled at runtime.** Without `BASHKIT_ALLOW_INPROCESS_SQLITE=1`, the builtin refuses to execute. - **`ATTACH` / `DETACH` rejected by policy.** A pre-execution check refuses both keywords (case-insensitive, comment-aware) so cross-DB access can't bypass VFS isolation. - **PRAGMA deny list.** `SqliteLimits::pragma_deny` blocks resource and filesystem-shaped knobs (`cache_size`, `mmap_size`, `page_size`, `temp_store_directory`, `compile_options`, `locking_mode`, …) by default. Common operational PRAGMAs (`user_version`, `wal_checkpoint`, `foreign_keys`, `journal_mode`) pass through. Override with `SqliteLimits::pragma_deny([...])`. - **Bounded recursion in `.read`.** ## Compatibility with the `sqlite3` shell The CLI surface is intentionally a subset of `sqlite3`'s. The pinned parity tests live in `tests/sqlite_compat_tests.rs`. Notable differences: - No interactive REPL. - No `ATTACH`, `DETACH`, `.load`, `.eqp`, or `.fullschema`. - Dot-commands must be on their own line (no inline `;` mixing). See also: - [`python_guide`](crate::python_guide), the embedded Python builtin, same opt-in pattern. - [`live_mounts_guide`](crate::live_mounts_guide), mount points the sqlite builtin reads from / writes to. - [`threat_model`](crate::threat_model), overall security model. --- # SSH support Source: https://bashkit.sh/docs/ssh.md Bashkit provides `ssh`, `scp`, and `sftp` builtins for remote command execution and file transfer over SSH. The default transport uses [russh](https://crates.io/crates/russh). **See also:** [`knowledge/integrations/ssh-support.md`][spec] ## Quick Start ```rust,no_run use bashkit::{Bash, SshConfig}; # #[tokio::main] # async fn main() -> bashkit::Result<()> { let mut bash = Bash::builder() .ssh(SshConfig::new().allow("supabase.sh")) .build(); let result = bash.exec("ssh supabase.sh").await?; # Ok(()) # } ``` ## Usage ```bash # Remote command ssh host.example.com 'uname -a' # Heredoc ssh host.example.com <<'EOF' psql -c 'SELECT version()' EOF # Shell session (TUI services like supabase.sh) ssh supabase.sh # SCP scp local.txt host.example.com:/remote/path.txt scp host.example.com:/remote/file.txt local.txt # SFTP (heredoc/pipe mode) sftp host.example.com <<'EOF' put /tmp/data.csv /var/import/data.csv get /var/export/report.csv /tmp/report.csv ls /var/import EOF ``` ## Configuration ```rust,no_run use bashkit::SshConfig; use std::time::Duration; let config = SshConfig::new() .allow("*.supabase.co") // wildcard subdomain .allow("bastion.example.com") // exact host .allow_port(2222) // additional port (default: 22 only) .default_user("deploy") // when no user@ prefix .timeout(Duration::from_secs(30)) // connection timeout .max_response_bytes(10_000_000) // max output size .max_sessions(5); // concurrent session limit ``` ## Authentication Tried in order: none (public services) → public key (`-i` flag or `default_private_key()`) → password (`default_password()`). ## Security - Default-deny host allowlist with glob patterns and port restrictions - Keys read from VFS only, never host `~/.ssh/` - Remote paths shell-escaped (TM-SSH-008) - Response size and session count limits [spec]: https://github.com/everruns/bashkit/blob/main/knowledge/integrations/ssh-support.md --- # Git Source: https://bashkit.sh/docs/git.md Bashkit ships a sandboxed `git` builtin behind the `git` feature flag. Every operation runs against the in-memory [virtual filesystem](filesystem.md), there is no host `~/.gitconfig`, no host credentials, and no network unless you explicitly allow a remote. Scripts get a familiar `git` workflow; the host stays isolated. ## Quick start ```rust,no_run use bashkit::{Bash, GitConfig}; # #[tokio::main] # async fn main() -> bashkit::Result<()> { let mut bash = Bash::builder() .git(GitConfig::new().author("Ada Lovelace", "ada@example.com")) .build(); bash.exec("git init").await?; bash.exec("echo 'hello' > README.md").await?; bash.exec("git add README.md").await?; bash.exec("git commit -m 'first commit'").await?; bash.exec("git log -n 1").await?; # Ok(()) # } ``` The commit identity comes from `GitConfig`, not from the host, see [TM-GIT-002](security.md). With no author configured, Bashkit uses a neutral virtual identity rather than reading any host config. ## Supported commands | Area | Commands | |------|----------| | **Local** | `init`, `config`, `add`, `commit -m`, `status`, `log [-n N]` | | **Branching** | `branch [-d]`, `checkout [-b]`, `diff` (simplified), `reset [--soft\|--mixed\|--hard]` | | **Remotes** | `remote [-v]`, `remote add/remove` (fully functional) | | **Remote transfer** | `clone`, `push`, `pull`, `fetch`, validate the URL against the allowlist, then return virtual-mode messages (no network in VFS-only mode) | `merge`, `rebase`, and `stash` are not yet implemented. ```bash git init git config user.name "Ada" echo "data" > file.txt git add . git commit -m "add file" git status git checkout -b feature git log -n 5 ``` ## Remote operations Remote transfer commands operate in **virtual mode**: the URL is validated, but no bytes cross the network. Remotes must be allowlisted explicitly, and only HTTPS is accepted, no SSH and no `git://`. ```rust,no_run use bashkit::GitConfig; let config = GitConfig::new() .author("Ada Lovelace", "ada@example.com") .allow_remote("https://github.com/everruns/*"); ``` A `git push https://evil.example/repo.git` is rejected before any URL parsing side effects, because the host is not in the remote allowlist. ## Storage model Phase 1 uses a simplified on-VFS layout (`.git/HEAD`, `.git/config`, `.git/index`, `.git/commits`, `.git/refs/heads/`). This gives full VFS isolation and correct user-facing behaviour today, and is the foundation for a future [`gix`](https://crates.io/crates/gix) backend with real object format and networked remotes. ## Security - **No host config or credentials**: identity is virtual and configurable (TM-GIT-002, TM-GIT-004). - **No repository escape**: every path stays inside the VFS (TM-GIT-005). - **Remote allowlist**: HTTPS-only, explicit patterns, no implicit network (TM-GIT-003). - **DoS caps**: object count, history depth, and pack size are bounded by the filesystem and `log` limits (TM-GIT-007/008/009). See the [security model](security.md) for the sandbox boundaries that apply to every builtin. ## See also - [Virtual filesystem](filesystem.md), where the repository lives. - [Security](security.md), sandbox boundaries and the `TM-GIT-*` threats. - Spec: [`knowledge/integrations/git-support.md`](https://github.com/everruns/bashkit/blob/main/knowledge/integrations/git-support.md). --- # Compatibility Source: https://bashkit.sh/docs/compatibility.md > Feature parity tracking for bash and common tools **See also:** - [API Documentation](https://docs.rs/bashkit) - Full API reference - [Custom Builtins Guide](./custom_builtins.md) - Extending Bashkit with custom commands - [Threat Model](./threat-model.md) - Security considerations **Legend:** ✅ Implemented | ⚠️ Partial | ❌ Not implemented | N/A Security exclusion ## POSIX Shell Compliance Bashkit provides substantial compliance with IEEE Std 1003.1-2024 (POSIX.1-2024) Shell Command Language. See [knowledge/operations/limitations.md](../../../knowledge/operations/limitations.md) for detailed compliance status. | POSIX Category | Status | |----------------|--------| | Reserved Words (16) | Full compliance | | Special Parameters (8) | Full compliance | | Special Built-ins (15) | 14/15 implemented | | Word Expansions | Substantial compliance | | Redirections | Full compliance | | Compound Commands | Full compliance | **Security Exclusions**: `exec` is intentionally not implemented for sandbox security reasons. See the compliance spec for details. ## Quick Status | Group | Count | |-------|-------| | Always-on builtins | 142 | | Feature-gated builtins (`jq`, `git`, `ssh`/`scp`/`sftp`, `python`/`python3`, `sqlite`/`sqlite3`, `ts`/`typescript`/`node`/`deno`/`bun`) | 14 | | **Total** | **156** | --- ## Builtins Reference ### Implemented | Builtin | Flags/Features | Notes | |---------|----------------|-------| | `echo` | `-n`, `-e`, `-E` | Basic escape sequences | | `printf` | `%s`, `%d`, `%u`, `%x`, `%o`, `%f`, width/precision | GNU-style integer precision; repeats format for multiple args | | `cat` | (none) | Concatenate files/stdin | | `true` | - | Exit 0 | | `false` | - | Exit 1 | | `exit` | `[N]` | Exit with code | | `cd` | `[dir]` | Change directory | | `pwd` | - | Print working directory | | `test` | `-f`, `-d`, `-e`, `-z`, `-n`, `-eq`, `-ne`, `-lt`, `-gt`, `-le`, `-ge` | Conditionals | | `[` | (same as test) | Alias for test | | `export` | `VAR=value` | Export variables | | `read` | `VAR`, `-r`, `-a`, `-d`, `-n`, `-p`, `-s`, `-t` | Read a record; without `-r` backslashes escape and `\` continues; status 1 at end of input | | `set` | `-e`, `+e`, positional | Set options and positional params | | `unset` | `VAR` | Unset variable | | `shift` | `[N]` | Shift positional params | | `local` | `VAR=value` | Local variables | | `source` | `file [args]` | Source script; loads functions/variables, PATH search, positional params | | `.` | `file [args]` | Alias for source | | `/path/to/script.sh` | `[args]` | Execute script by absolute/relative path (call frame; a `#!` naming a registered builtin such as `python3` or `awk -f` runs that builtin, other shebangs are stripped) | | `$PATH` search | `cmd [args]` | Search `$PATH` dirs for executable scripts (after builtins) | | `break` | `[N]` | Break from loop | | `continue` | `[N]` | Continue loop | | `return` | `[N]` | Return from function | | `:` | - | POSIX null utility (no-op) | | `eval` | `command...` | POSIX construct and execute command | | `readonly` | `VAR[=value]`, `-p` | POSIX mark variable read-only | | `times` | - | POSIX display process times | | `grep` | `-G/-E/-F/-P`, `-e`, `-f`, `-i/-y`, `--no-ignore-case`, `-v`, `-w`, `-x`, `-c`, `-l`, `-L`, `-o`, `-q`, `-s`, `-m`, `-n`, `-b`, `-H/-h`, `--label`, `-T`, `-Z`, `-z`, `-a`, `-I`, `--binary-files`, `-A/-B/-C/-NUM`, `--group-separator`, `--no-group-separator`, `-r/-R`, `-d`, `-D`, `--include/--exclude/--exclude-from/--exclude-dir`; long-option prefixes | GNU grep 3.11 output; BRE/ERE leftmost-longest, back-references; recursive ancestor loops skipped with a warning, files scanned under shared resource budgets | | `rg` | `-i/-S/-s`, `-n/-N`, `--column`, `-b/--byte-offset`, `--vimgrep`, basic `--json`, `--stats`, `--null`, `-c`, `--count-matches`, `--include-zero`, `-l`, `--files-with-matches`, `--files-without-match`, `--files`, `-F`, `-w`, `-x`, `-a/--text`, `--binary`, `-m`, `-M/--max-columns`, `--max-columns-preview`, `--max-depth`, `-A`, `-B`, `-C`, separator flags, `--heading/--no-heading`, `--sort/--sortr`, `--path-separator`, `-g/--glob`, `--ignore-file`, `.ignore`/`.gitignore`, `--no-ignore*`, `--require-git/--no-require-git`, `-u/--unrestricted`, `--messages/--no-messages`, `-t/--type`, `-T/--type-not`, `--type-add`, `--type-clear`, `--type-list`, `--hidden/--no-hidden`, `-o`, `-q`, `-e`, `-f`, `-E/--encoding` (`auto`, `none`, UTF-8, UTF-16LE/BE), `-r`, `--passthru`, `--trim`, `-H/-I`, `--no-config`, `--line-buffered`, `--block-buffered`, `--mmap/--no-mmap`, `-P/--pcre2`, `--no-pcre2`, `--engine`, `--auto-hybrid-regex`, `--no-auto-hybrid-regex`, `--help`, `--version` | Recursive ripgrep-style search | | `sed` | `s///[gpiImMN w]`, `y`, `d`, `D`, `p`, `P`, `n`, `N`, `q[N]`, `Q[N]`, `=`, `l[N]`, `z`, `F`, `a`, `i`, `c`, `r`, `R`, `w`, `W`, `h/H/g/G/x`, `b`/`t`/`T`/`:label`, `{}`, `#`/`#n`; addresses `N`, `$`, `/re/[IM]`, `\cREc`, `first~step`, `a,b`, `a,+N`, `a,~N`, `0,/re/`, `!`; options `-n -e -f -i[SUF] -E/-r -s -z -l N -u --` and long forms | Stream editing; one stream across operands unless `-s`/`-i` | | `awk` (also `gawk`, `mawk`, `nawk`) | `'{print}'`, `-F`, `-v`, loops, arrays, increment, ternary | Text processing | | `jq` | `.field`, `.[n]`, pipes, file args, `-r`, `-c`, `-n`, `-s`, `-S`, `-e`, `-j`, `-f`/`--from-file`, `--tab`, `--arg`, `--argjson`, `-V`, combined flags | JSON processing | | `sleep` | `N`, `N.N` | Pause execution (max 60s) | | `head` | `-n N`, `-N` | First N lines (default 10) | | `tail` | `-n N`, `-N` | Last N lines (default 10) | | `basename` | `NAME [SUFFIX]` | Strip directory from path | | `dirname` | `NAME` | Strip last path component | | `mkdir` | `-p` | Create directories | | `rm` | `-rf` | Remove files/directories | | `cp` | `-r` | Copy files | | `mv` | - | Move/rename files | | `touch` | `-c`, `-d`/`--date`, `-r`/`--reference`, `-t`, `-a`/`-m` | Create files, set modification time | | `chmod` | `MODE` | Change permissions (octal only) | | `wc` | `-l`, `-w`, `-c` | Count lines/words/bytes | | `sort` | `-r`, `-n`, `-u` | Sort lines | | `uniq` | `-c`, `-d`, `-u` | Filter duplicate lines | | `cut` | `-d DELIM`, `-f FIELDS` | Extract fields | | `tr` | `-d`, character ranges | Translate/delete chars | | `date` | `+FORMAT`, `-u`, `-R`, `-I`, `-r`, `-d`/`--date` (relative, weekday, compound, epoch, ISO date-times with optional seconds and zone) | Display/format date with sandboxed IANA timezone support | | `wait` | `[-n] [-p VAR] [-f] [ID...]` (PID or `%jobspec`) | Wait for background jobs | | `jobs` | `-l`, `-p` | List running background jobs | | `ps` | `-f`, `aux`, `-o`, `-p` | Virtual process list (shell + jobs) | | `pgrep`/`pkill` | `-f`, `-l`, `-a`, `-c`, `-x`, `-SIGNAL` | Match jobs by command | | `disown`, `fg`, `bg` | `[JOBSPEC]` | `fg` waits; `bg` is a no-op (jobs already run) | | `uuidgen` | `-r` | Random (v4) UUID from the OS CSPRNG | | `openssl` | `rand [-hex\|-base64] [-out FILE] NUM` | Only `rand`; other subcommands fail with `Invalid command` | | `make` | `-f`, `-C`, `-n`, `-k`, `-q`, `-s`, `-i`, `-B`, `-e`, `-j` (sequential), `VAR=value` | GNU make subset; recipes run in the sandbox shell; no built-in implicit rules, `$(eval)` or `vpath` (L-MAKE-001..003) | | `curl` | `-s`, `-o`, `-X`, `-d`/`--data`, `--data-raw`, `--data-binary`, `--data-urlencode`, `-G`/`--get`, `-H`, `-I`, `-f`, `-L`, `-w`, `--compressed`, `-u`, `-A`, `-e`, `-v`, `-m` | HTTP client (requires http_client feature) | | `wget` | `-q`, `-O`, `--spider`, `--header`, `-U`, `--post-data`, `-t` | Download files (requires http_client feature) | | `time` | `[-p] [-f FORMAT] [-o FILE] [-a] [-v] [--] PIPELINE` | Reserved-word timing; elapsed/status/Bashkit counters are truthful, host CPU/RSS fields say `unavailable` | | `timeout` | `DURATION COMMAND` | Run with time limit (stub) | | `ls` | `-l`, `-a`, `-h`, `-1`, `-R`, `-t`, `-F`, `-C`, `-d`, `-b`, `-N`, `-Q`, `--quoting-style` (not `locale`/`clocale`) | List directory contents | | `find` | GNU expression grammar (`( )`, `!`, `-a`, `-o`, `,`), `-H`/`-L`/`-P`, `-name`/`-iname`, `-path`/`-ipath`, `-regex`/`-iregex`/`-regextype`, `-type`/`-xtype`, `-size`, `-empty`, `-mtime`/`-mmin`/`-newer`/`-newerXY`, `-perm`, `-user`/`-group`/`-uid`/`-gid`, `-maxdepth`/`-mindepth`/`-depth`, `-print`/`-print0`/`-printf`, `-exec`/`-execdir` (`;` and `+`), `-delete`, `-prune`, `-quit` | Search for files (gaps: L-FIND-001) | | `rmdir` | `-p` | Remove empty directories | | `xargs` | `-I`, `-n`, `-d` | Build commands from stdin | | `tee` | `-a` | Write to files and stdout | | `watch` | `INTERVAL COMMAND` | Execute periodically (virtual mode) | | `file` | (none) | Detect file type via magic bytes | | `less` | (none) | View file (behaves like cat in virtual mode) | | `stat` | `-c FORMAT` | Display file metadata | | `tar` | `-c`, `-x`, `-t`, `-v`, `-f`, `-z`/`--gzip`, `-j`/`--bzip2`, `-C`, `-O`, `-P`/`--absolute-names`, `-T`/`--files-from`, `--strip-components`, `--exclude`, `--` | Archive operations; auto-detects gzip/bzip2 while reading | | `gzip` | `-d`, `-k`, `-f` | Compress files | | `gunzip` | `-k`, `-f` | Decompress files | | `bzip2` | `-c`, `-d`, `-z`, `-k`, `-f` | Compress or decompress bzip2 streams/files | | `bunzip2` | `-c`, `-k`, `-f` | Decompress bzip2 streams/files | | `bzcat` | | Decompress bzip2 streams/files to stdout | | `env` | `-i`, `-u`, `-C`, `-0`, `[VAR=val]`, `CMD [ARGS]` | Print environment or run a command in a modified one | | `printenv` | `[VAR]` | Print environment variables | | `history` | (none) | Command history (limited in virtual mode) | | `hostname` | (none) | Display virtual hostname | | `uname` | `-a`, `-s`, `-n`, `-r`, `-v`, `-m`, `-o` | System info | | `whoami` | (none) | Display virtual username | | `id` | `-u`, `-g`, `-n` | User/group IDs | | `nl` | `-b`, `-n`, `-s`, `-i`, `-v`, `-w` | Number lines of files | | `paste` | `-d`, `-s` | Merge lines of files | | `column` | `-t`, `-s`, `-o` | Columnate lists | | `comm` | `-1`, `-2`, `-3` | Compare two sorted files | | `diff` | `-u`, `-q`/`--brief` | Compare files line by line | | `strings` | `-n`, `-t`, `-a` | Find printable strings in binary data | | `od` | `-A`, `-t`, `-N`, `-j` | Octal/hex dump | | `xxd` | `-l`, `-s`, `-c`, `-g`, `-p` | Hex dump | | `hexdump` | `-C`, `-n`, `-s` | Display file in hex+ASCII | #### `time` reports `time` wraps a shell pipeline, so groups, functions, redirects, nested shells, and pipeline status behave as shell syntax rather than utility arguments. `-p` uses the POSIX layout. `-f` supports elapsed seconds (`%e`), elapsed hours/minutes/seconds (`%E`), exit status (`%x`), literal percent (`%%`), and Bashkit counters (`%{commands}`, `%{loops}`, `%{work_units}`). GNU host-process fields such as `%U`, `%S`, `%P`, and `%M` return `unavailable`; Bashkit never attributes the embedding process's CPU or memory to a wrapped command. `-v` reports the same available data with labels. `-o FILE` atomically replaces the report file in the VFS; `-a -o FILE` appends. Wrapped stdout and stderr are unchanged. Hardened profiles expose elapsed time in 100 ms lower-bound buckets. #### `date` timezone contract `date` defaults to UTC when the shell environment has no `TZ`; it never reads the host process timezone. A valid IANA identifier or alias in `TZ` (for example `America/Chicago`, `Etc/GMT+6`, or `UTC`) controls display and the interpretation of timezone-naive `-d` values. Explicit input offsets and `Z` identify an instant independently of `TZ`; the output is then rendered in the selected zone. `-u` always renders UTC while naive input still uses the selected sandbox zone. IANA transition rules apply at DST boundaries. Nonexistent local wall times fail; repeated wall times choose the earlier instant deterministically. Empty, invalid, POSIX-rule, and path-style `TZ` values fail closed to UTC. Formats use Chrono's validated strftime implementation, plus GNU `%N`, `%3N`, `%6N`, and `%9N` fractional-second forms. ### Recently Added | Builtin | Flags / Arguments | Notes | |---------|-------------------|-------| | `ln` | `-s`, `-f` | Create links | | `chown` | `OWNER[:GROUP] FILE` | Change ownership (virtual) | | `kill` | `-l`, `-s SIG`, `-n NUM`, `-SIG`, `PID`/`%job` | Terminate background jobs (exit 128+signal); stop/continue signals are ignored | | `trap` | `COMMAND SIGNAL...`, `-p`, `-l` | Signal/event handlers | | `type` | `NAME...` | Describe command type | | `which` | `NAME...` | Locate a command | | `command` | `-v`, `NAME...` | Run or identify commands | | `hash` | (none) | No-op in sandboxed env | | `declare`/`typeset` | `-i`, `-r`, `-x`, `-a`, `-p`, `-n`, `-l`, `-u` | Variable attributes | | `let` | `EXPR...` | Evaluate arithmetic | | `getopts` | `OPTSTRING NAME` | Parse positional parameters | | `caller` | `[FRAME]` | Display call stack frame | | `mapfile` | `-n`, `-O`, `-s`, `-t`, `-d` | Read lines into array | | `readarray` | `-n`, `-O`, `-s`, `-t`, `-d` | Alias for mapfile | | `shopt` | `-s`, `-u`, `-q`, `-p`, `-o` | Shell options; `-o` acts on `set -o` names | | `seq` | `[FIRST [INCR]] LAST` | Print number sequence | | `tac` | (none) | Reverse file lines | | `rev` | (none) | Reverse characters per line | | `yes` | `[STRING]` | Output repeated string | | `expr` | `EXPRESSION` | Evaluate expressions | | `mktemp` | `-d`, `-p`, `-t` | Create temporary files | | `realpath` | `PATH` | Resolve path | | `pushd`/`popd`/`dirs` | standard flags | Directory stack | | `arch` | (none) | Virtual machine name (`x86_64`) | | `sum` | `-r`, `-s` | BSD and System V checksums, GNU output format | | `shasum` | `-a 1/224/256/384/512` | Perl `shasum` front end over the `sha*sum` builtins | | `egrep`/`fgrep` | `grep` flags | `grep -E` / `grep -F` | | `link`/`unlink` | `FILE1 FILE2` / `FILE` | Single-file link (a symlink, L-FS-001) and remove | | `chgrp` | `GROUP FILE...` | Change group (virtual, like `chown`) | | `nohup`/`nice` | `COMMAND...`, `nice -n N` | Run the command; no signal or priority effect in a sandbox | | `flock` | `FILE CMD`, `FILE -c CMD`, `-n FD` | Creates the lock file and runs the command; locks are uncontended in one session | | `getconf` | `NAME`, `-a` | POSIX config values, consistent with `nproc` and `/proc/meminfo` | | `tty` | `-s` | "not a tty" unless the embedder sets `tty(0, true)`, like `[ -t 0 ]` | | `sync`/`hostid` | (none) | No-op / fixed virtual host id | | `groups`/`logname` | `[USER]` | Virtual user only | | `users`/`who` | (none) | Empty: no login sessions | | `uptime` | `-p`, `-s` | Derived from the virtual clock | | `free` | `-b`, `-k`, `-m`, `-g`, `-h`, `-t` | Fixed virtual memory, consistent with `/proc/meminfo` | ### Not Implemented | Builtin | Priority | Status | |---------|----------|--------| | `exec` | N/A | Security: intentionally excluded | --- ## Shell Syntax ### Operators | Operator | Status | Example | Notes | |----------|--------|---------|-------| | `\|` | ✅ | `cmd1 \| cmd2` | Pipeline; each stage is a subshell, `shopt -s lastpipe` keeps the last stage in the shell | | `&&` | ✅ | `cmd1 && cmd2` | AND list | | `\|\|` | ✅ | `cmd1 \|\| cmd2` | OR list | | `;` | ✅ | `cmd1; cmd2` | Sequential | | `&` | ⚠️ | `cmd &` | Parsed, async pending | | `!` | ✅ | `! cmd` | Negate exit code | ### Redirections | Redirect | Status | Example | Notes | |----------|--------|---------|-------| | `>` | ✅ | `cmd > file` | Output to file | | `>>` | ✅ | `cmd >> file` | Append to file | | `<` | ✅ | `cmd < file` | Input from file | | `<<<` | ✅ | `cmd <<< "string"` | Here-string | | `<` | ✅ | `cmd 2> file` | Stderr redirect | | `2>&1` | ✅ | `cmd 2>&1` | Stderr to stdout | | `&>` | ✅ | `cmd &> file` | Both to file | | `N>`/`N>>` (N≥3) | ✅ | `{ cmd >&3; } 3>file` | Opens fd N only; stdout untouched | ### Control Flow | Feature | Status | Example | |---------|--------|---------| | `if/elif/else/fi` | ✅ | `if cmd; then ...; fi` | | `for/do/done` | ✅ | `for i in a b c; do ...; done` | | `while/do/done` | ✅ | `while cmd; do ...; done` | | `until/do/done` | ✅ | `until cmd; do ...; done` | | `case/esac` | ✅ | `case $x in pat) ...;; esac` | | `{ ... }` | ✅ | Brace group | | `( ... )` | ✅ | Subshell | | `function name { }` | ✅ | Function definition | | `name() { }` | ✅ | Function definition | --- ## Expansions ### Variable Expansion | Syntax | Status | Example | Description | |--------|--------|---------|-------------| | `$var` | ✅ | `$HOME` | Simple expansion | | `${var}` | ✅ | `${HOME}` | Braced expansion | | `${var:-default}` | ✅ | `${X:-fallback}` | Use default if unset/empty | | `${var:=default}` | ✅ | `${X:=value}` | Assign default if unset/empty | | `${var:+alt}` | ✅ | `${X:+yes}` | Use alt if set | | `${var:?error}` | ✅ | `${X:?missing}` | Error if unset/empty | | `${#var}` | ✅ | `${#str}` | Length of value | | `${var#pat}` | ✅ | `${f#*.}` | Remove shortest prefix | | `${var##pat}` | ✅ | `${f##*/}` | Remove longest prefix | | `${var%pat}` | ✅ | `${f%.*}` | Remove shortest suffix | | `${var%%pat}` | ✅ | `${f%%/*}` | Remove longest suffix | | `${var/pat/repl}` | ✅ | `${s/foo/bar}` | Substitute first match | | `${var//pat/repl}` | ✅ | `${s//o/0}` | Substitute all matches | | `${var^}` | ✅ | `${s^}` | Uppercase first | | `${var^^}` | ✅ | `${s^^}` | Uppercase all | | `${var,}` | ✅ | `${s,}` | Lowercase first | | `${var,,}` | ✅ | `${s,,}` | Lowercase all | | `${var^^pat}` | ✅ | `${s^^[aeiou]}` | Case change only for characters matching `pat` (also `^`, `,`, `,,`) | | `${var@op}` | ✅ | `${s@Q}` | Transform: `Q` quote, `E` escapes, `U`/`u`/`L` case, `A` assignment, `a` attributes | | `${arr[@]op}` | ✅ | `${a[@]/x/y}`, `${a[@]^^}`, `${a[@]@Q}` | Pattern, case and transform operators apply per element (also `${a[i]op}`, `$@`, `$*`) | ### Prefix Environment Assignments | Syntax | Status | Example | Description | |--------|--------|---------|-------------| | `VAR=val cmd` | ✅ | `TOKEN=abc printenv TOKEN` | Temporary env for command | | Multiple prefix | ✅ | `A=1 B=2 cmd` | Multiple vars in one command | | No persist | ✅ | `X=1 cmd; echo $X` | Var not set after command | | Assignment-only | ✅ | `X=1` (no cmd) | Persists in shell variables | ### Command Substitution | Syntax | Status | Example | |--------|--------|---------| | `$(cmd)` | ✅ | `x=$(pwd)` | | `` `cmd` `` | ✅ | Backticks, including after quoted word segments; unquoted output splits and globs | ### Arithmetic | Syntax | Status | Example | |--------|--------|---------| | `$((expr))` | ✅ | `$((1+2))` | | `+`, `-`, `*`, `/`, `%` | ✅ | Basic ops | | `==`, `!=`, `<`, `>`, `<=`, `>=` | ✅ | Comparisons | | `&`, `\|` | ✅ | Bitwise | | `&&`, `\|\|` | ✅ | Logical operators | | `? :` | ✅ | Ternary | | `=`, `+=`, etc. | ✅ | Assignment operators | ### Other Expansions | Syntax | Status | Example | Description | |--------|--------|---------|-------------| | `*`, `?` | ✅ | `*.txt` | Glob patterns | | `[abc]` | ✅ | `[0-9]` | Bracket globs | | `{a,b,c}` | ✅ | `{1..5}` | Brace expansion | | `~` | ✅ | `~/file`, `~+`, `~-` | Tilde expansion: HOME, PWD, OLDPWD | | `<(cmd)` | ✅ | `diff <(a) <(b)` | Process substitution | --- ## Special Variables | Variable | Status | Description | |----------|--------|-------------| | `$?` | ✅ | Last exit code | | `$#` | ✅ | Number of positional params | | `$@` | ✅ | All positional params (separate) | | `$*` | ✅ | All positional params (joined) | | `$0` | ✅ | Script/function name | | `$1`-`$9` | ✅ | Positional parameters | | `$!` | ✅ | Last background job ID (POSIX) | | `$$` | ✅ | Current PID | | `$-` | ✅ | Current option flags (POSIX) | | `$_` | ❌ | Last argument | | `$RANDOM` | ✅ | Random number (0-32767) | | `$SRANDOM` | ✅ | 32-bit random number from the OS CSPRNG (bash 5.1); assignment ignored | | `$SECONDS` | ✅ | Whole seconds since the shell (the `Bash` instance) started; `SECONDS=N` restarts the count from N | | `$LINENO` | ✅ | Current line number | --- ## Arrays | Feature | Status | Example | |---------|--------|---------| | Declaration | ✅ | `arr=(a b c)` | | Index access | ✅ | `${arr[0]}` | | All elements `@` | ✅ | `${arr[@]}` (separate args) | | All elements `*` | ✅ | `${arr[*]}` (single arg when quoted) | | Array length | ✅ | `${#arr[@]}` | | Element length | ✅ | `${#arr[0]}` | | Append | ✅ | `arr+=(d e)` | | Slice | ✅ | `${arr[@]:1:2}` | | Indices | ✅ | `${!arr[@]}` | | Associative | ✅ | `declare -A` | --- ## Test Operators ### File Tests | Operator | Status | Description | |----------|--------|-------------| | `-e file` | ✅ | Exists | | `-f file` | ✅ | Is regular file | | `-d file` | ✅ | Is directory | | `-s file` | ✅ | Size > 0 | | `-r file` | ✅ | Is readable (exists in virtual fs) | | `-w file` | ✅ | Is writable (exists in virtual fs) | | `-x file` | ✅ | Is executable (mode & 0o111) | | `-L file` | ✅ | Is symlink | ### String Tests | Operator | Status | Description | |----------|--------|-------------| | `-z str` | ✅ | Is empty | | `-n str` | ✅ | Is non-empty | | `str1 = str2` | ✅ | Equal | | `str1 != str2` | ✅ | Not equal | | `str1 < str2` | ✅ | Less than | | `str1 > str2` | ✅ | Greater than | ### Numeric Tests | Operator | Status | Description | |----------|--------|-------------| | `-eq` | ✅ | Equal | | `-ne` | ✅ | Not equal | | `-lt` | ✅ | Less than | | `-gt` | ✅ | Greater than | | `-le` | ✅ | Less or equal | | `-ge` | ✅ | Greater or equal | --- ## Resource Limits Default limits (configurable): | Resource | Default | Notes | |----------|---------|-------| | Commands | 10,000 | Per execution | | Loop iterations | 10,000 | Per loop | | Total loop iterations | 1,000,000 | Per execution | | Function depth | 100 | Recursion limit | | Output size | 1MiB each | Separate stdout and stderr caps | | Parser timeout | 5s | Prevents infinite parse | | Parser operations | 100,000 | Fuel-based limit | | Input size | 10MB | Max script size | | AST depth | 100 | Nesting limit | --- ## Filesystem | Feature | Status | Notes | |---------|--------|-------| | Virtual filesystem | ✅ | InMemoryFs, OverlayFs, MountableFs | | Real filesystem | ❌ | Virtual by default | | Symlinks | ✅ | Followed in the VFS (40-link cap); `..` resolves lexically | | Permissions | ✅ | Metadata stored, not enforced | | `/dev/null` | ✅ | Interpreter-level handling (cannot be bypassed) | | `/dev/zero` | ✅ | 1 MiB of zeros per read | | `/etc`, `/proc` | ⚠️ | Synthetic, read-only (`os-release`, `passwd`, `cpuinfo`, ...); `rootfs(false)` disables | | `/bin`, `/usr/bin` | ✅ | Stub per builtin; `which`/`type` report paths, running a stub runs the builtin | --- ## Network | Feature | Status | Notes | |---------|--------|-------| | HTTP client | ✅ | Full implementation with security mitigations | | URL allowlist | ✅ | Default-deny whitelist security model | | `curl` builtin | ✅ | Full HTTP client; ordered data variants, `@file`, and `-G` query aggregation included | | `wget` builtin | ✅ | Full downloader with `-q`, `-O`, `--spider`, `--header`, `-U`, `--post-data`, `-t` | | Response limits | ✅ | 10MB max response size, 30s timeout | | Redirect security | ✅ | Redirects require explicit `-L` and allowlist check | | Raw sockets | ❌ | Not planned | ### Network Configuration ```rust,ignore use bashkit::{Bash, NetworkAllowlist}; // Enable network with URL allowlist let bash = Bash::builder() .network(NetworkAllowlist::new() .allow("https://api.example.com") .allow("https://cdn.example.com/assets")) .build(); ``` See [knowledge/security/threat-model.md](../../../knowledge/security/threat-model.md) for HTTP security details. --- ## Running Tests ```bash # All tests cargo test --all-features # Spec tests only cargo test --test spec_tests # Compare with real bash cargo test --test spec_tests -- bash_comparison_tests --ignored ``` --- ## Roadmap ### Completed - [x] `sleep` builtin - [x] `head`/`tail` builtins - [x] File operation builtins (`mkdir`, `rm`, `cp`, `mv`, `touch`, `chmod`) - [x] `wc` builtin - [x] Text processing (`sort`, `uniq`, `cut`, `tr`) - [x] Text structure (`nl`, `paste`, `column`) - [x] File comparison (`diff`, `comm`) - [x] Byte inspection (`strings`, `od`, `xxd`, `hexdump`) - [x] `basename`/`dirname` builtins - [x] `date` builtin - [x] Background execution (`&`, `wait`) - jobs run concurrently on the session's task (no stop/continue) - [x] Network (`curl`, `wget`) - full HTTP implementation with security mitigations - [x] `timeout` builtin - stub, requires interpreter-level integration - [x] Process substitution (`<(cmd)`, `>(cmd)`) - [x] Here string edge cases tested - [x] `set -e` (errexit) - exit on command failure - [x] Tilde expansion (~) - expands to $HOME - [x] Special variables ($$, $RANDOM, $LINENO) - [x] File test operators (-r, -w, -x, -L) - [x] Stderr redirections (2>, 2>&1, &>) - [x] Arithmetic logical operators (&&, ||) - [x] Brace expansion ({a,b,c}, {1..5}) - [x] String comparison operators (< >) in test - [x] Array indices `${!arr[@]}` - [x] `/dev/null` support (interpreter-level, cannot be bypassed by custom fs) ### Known LLM Compatibility Gaps (Resolved) Identified from eval analysis, all items now implemented: **High Impact (commonly generated by LLMs):** - [x] `chmod +x` symbolic mode, `apply_symbolic_mode()` in fileops.rs - [x] `sed` ampersand (`&`) in replacement, PR #196 - [x] AWK `printf %x/%o/%c` format specifiers, hex/octal output - [x] AWK `match()` and `gensub()` functions, text extraction - [x] `sed` `\n` literal newline in replacement, line splitting **Medium Impact:** - [x] AWK power operators (`^`, `**`), math scripts - [x] AWK `exit` statement with code, error handling - [x] AWK negation `!$1`, filtering empty fields - [x] `sed` grouped commands `{cmd1;cmd2}`, PR #227 - [x] `sed` branch/label (`b`/`t`/`:label`), branching support - [x] AWK `ORS` variable, custom output formatting - [x] AWK `getline`, multi-file processing **Low Impact:** - [x] `sed` `0~2` step addressing, even/odd line processing - [x] `sed` `Q` quiet quit command - [x] `sed` `0,/pattern/` first match addressing - [x] `sed` GNU rewrite, issue #2427: literal `$` in the replacement, BRE/ERE translation shared by `s///` and address regexes, stateful range addresses, single-stream multi-file input, preserved missing final newline, `s///Ng`, the `y = l n N P D T z F r R w W` commands, `#` comments and `#n`, atomic mode-preserving `-i`, and full getopt-style option parsing - [x] AWK `$0` modification with field re-splitting - [x] AWK regex field separators (`-F' +'`, `FS = "[,;]"`) and regex `split()` separators ### Not Planned - Interactive features (history, job control UI) - Process spawning (virtual environment) - Raw filesystem access --- ## See Also - [knowledge/operations/limitations.md](../../../knowledge/operations/limitations.md) - Intentional gaps and partial features - [Bashkit knowledge](../../../knowledge/) - Persistent design and engineering knowledge --- # jq builtin Source: https://bashkit.sh/docs/jq.md Bashkit ships an embedded `jq` JSON processor backed by [jaq] with a thin compatibility shim layered on top. This guide documents which jq features are supported so callers (and LLM agents that generate jq filters against bashkit) can avoid surprises. [jaq]: https://github.com/01mf02/jaq ## Reported version `jq --version` prints `jq-1.8`. Filters generated for stedfan/jq 1.7 and 1.8 are the intended target. ## Command-line flags Implemented: | Flag | Description | |------|-------------| | `-r`, `--raw-output` | Strings are written without quotes | | `-R`, `--raw-input` | Each line of input becomes a JSON string | | `-s`, `--slurp` | Read every input value into one array | | `-n`, `--null-input` | Use `null` as the (single) input value | | `-c`, `--compact-output` | One JSON value per line, no pretty-printing | | `-S`, `--sort-keys` | Sort object keys recursively | | `-e`, `--exit-status` | Set exit code based on the output | | `-j` | Like `-r` but suppresses trailing newlines | | `--tab` | Use tabs for indentation | | `--indent N` | Indent with N spaces, 0..7 (default 2). `0` still pretty-prints — newlines, no leading spaces — so `-c` is the only way to get compact output | | `-a`, `--ascii-output` | Escape non-ASCII characters as `\uXXXX` (strings stay quoted even with `-r`) | | `--raw-output0` | Like `-r` with a NUL after each output; a string containing NUL is an error | | `--seq` | Write RS (0x1e) before each output; RS separates input values | | `--stream` | Feed each input as path events (`[path, leaf]`, `[path]`), as `tostream` does | | `--arg name value` | Bind `$name` to a string | | `--argjson name json` | Bind `$name` to a parsed JSON value | | `-f FILE`, `--from-file FILE` | Read the filter from FILE (VFS); all positionals become input files | | `-V`, `--version` | Print the version | | `-h`, `--help` | Print help | | Combined flags like `-snr` | Treated as the union of the individual flags | `-c`, `--tab` and `--indent N` all choose the same thing — the output shape — so the last one on the command line wins. `jq --indent 4 -c` is compact and `jq -c --indent 4` is indented four spaces. ## Variables | Variable | Behaviour | |----------|-----------| | `$ENV` | Bound to the shell environment as an object, same map as the `env` filter. (#1486) | | `$name` | Variables defined with `--arg` / `--argjson` are passed through. | ## Notable filters The full [jq stdlib] is mostly available via `jaq-std`. The compatibility shim adds or overrides: [jq stdlib]: https://jqlang.github.io/jq/manual/ | Filter | Notes | |--------|-------| | `env` | Reads from the shell env map (not the host process env), avoiding the unsafe `std::env::set_var` path. | | `setpath(p; v)` | Bashkit ships a recursive definition because jaq's stdlib doesn't expose one. | | `leaf_paths` | Defined as `paths(scalars)` since jaq's stdlib lacks it. | | `match(re; flags)` / `match(re)` | Overridden to add `"name": null` to unnamed captures, matching jq output. | | `scan(re; flags)` / `scan(re)` | Overridden so `scan` defaults to global ("g") matching, matching jq. | | `input_filename` | Name of the file the current value came from (`null` for stdin). | | `input_line_number` | Lines read so far, as jq counts them (jq reads a line at a time). | | `input` / `inputs` | Pull from the same stream as the main loop; with `-n` the whole stream is theirs. `input` fails with `No more inputs` at the end. | | Most other 1.7/1.8 stdlib filters | Forwarded from `jaq-std` (`getpath`, `paths`, `to_entries`, `group_by`, `ltrimstr`/`rtrimstr`, `splits`, `test`, `now`, `debug`, `limit`, etc.). | ## Numbers Computed numbers print like jq: `pow(2;10)` is `1024`, `1e17*1` is `1e+17`, `0.00001*1` is `1e-05`, NaN is `null` and infinities print as the largest double. Input literals print as read until arithmetic changes them (`1.0` stays `1.0`). Division by zero is an error, `%` truncates its operands to integers, a fractional array index is truncated, and `gamma` is the log-gamma function, all as in jq. Math and index errors use jq's wording, so `try ("a"+1) catch .` gives `string ("a") and number (1) cannot be added`. ## Errors Filter compile failures exit `3`. A runtime error is reported as `jq: error (at FILE:LINE): ...` (``, or `` under `-n`) and jq moves on to the next input; the exit status follows the last input (`5` if it failed). `error("msg")` prints `msg` unquoted, other values get jq's `(not a string)` marker. Values before a JSON syntax error are processed, then the error exits `5`. A file that cannot be opened prints `Could not open file F: reason`, is skipped, and makes the exit status `2`. With `-e` the last output decides: `1` for `null`/`false`, `4` when nothing was output. Long error operands are summarised so failures do not blow up an LLM context window, see [#1485](https://github.com/everruns/bashkit/issues/1485). ## Resource limits A filter cannot allocate without bound. Every live string, array and object counts against `ExecutionLimits::max_live_intermediate_bytes` (32 MB by default); growing past it fails before allocating with `jq: error: value size limit (N bytes) exceeded` and exit 5, and `try` cannot catch its way around it. Output is capped by `max_stdout_bytes`, and a filter that loops without emitting (`until(false; .)`) stops at the execution timeout with `jq: execution timed out`. `--stream` produces path events incrementally, sharing ancestor keys. Event and traversal storage obey the same memory limit, and traversal checks work, cancellation and timeout limits even with an `empty` filter. `--slurp` retains events and can hit the memory limit on otherwise small JSON input. The limit counts real in-memory size, which is several times the JSON text: a 5.6 MB array of 100,000 small objects fits the default, much larger inputs need a larger `max_live_intermediate_bytes`. ## Location `$__loc__` is `{"file":"","line":N}`, N being the filter line it appears on. ## Destructuring alternatives `. as [$a, $b] ?// {a: $a} | ...` tries each pattern in turn, as in jq: every variable named in any pattern is bound (null when its pattern did not set it), and an error moves on to the next pattern. ## Messages and halt `stderr`, `debug` and `halt_error` write to the jq command's stderr. `halt` and `halt_error` end the jq command (not the shell or host) with their exit code, skipping any remaining input. ## Known gaps Bashkit's jq is intentionally minimal in places where the host model differs from upstream jq: - `--seq` reads RS as whitespace; jq's rules for abandoned text between separators are not reproduced. - Exotic numeric formatting modes (`@base32`, `@base64d`, etc.) follow whatever `jaq-json` ships. If you hit a missing builtin, please open an issue with the failing filter. ## See also - [`compatibility_scorecard`](crate::compatibility_scorecard), overall builtin coverage table. - [`threat_model`](crate::threat_model), security model for `jq` against malicious input. --- # yq builtin Source: https://bashkit.sh/docs/yq.md Bashkit ships a `yq` structured-data processor for the command shape agents commonly use with mikefarah/yq. It parses YAML or JSON, evaluates the existing `jq`/jaq expression engine, then emits YAML or JSON. Bashkit deliberately does not maintain a second YAML-specific query language. Enable the Cargo `jq` feature to register both `jq` and `yq`. ## Examples ```bash yq '.server.port' config.yml yq '.items[] | select(.enabled) | .name' config.yml yq '.values | map(. * 2)' config.yml yq -o=json -I=0 '.' config.yml yq -p=json -o=yaml '.' data.json yq -i '.server.port = 8080' config.yml ``` With no expression, `.` is used. Input comes from the listed VFS files or stdin. YAML streams containing `---` are processed one document at a time; `-s` presents all input documents to the filter as one array. The optional `e` / `eval` subcommand alias is accepted for common generated invocations. ## Flags | Flag | Behaviour | |------|-----------| | `-p`, `--input-format` | `auto`, `yaml`, or `json` | | `-o`, `--output-format` | `yaml` or `json` | | `-r`, `--raw-output` | Unwrap string results | | `-c`, `--compact-output` | Compact JSON output | | `-e`, `--exit-status` | Nonzero for no output, `null`, or `false` | | `-s`, `--slurp` | Read all documents into an array | | `-n`, `--null-input` | Evaluate once with `null` input | | `-i`, `--inplace` | Atomically replace exactly one input file | | `-I`, `--indent` | Set JSON indentation; `0` is compact | | `-N`, `--no-doc` | Omit separators between YAML results | | `--expression` | Force an otherwise ambiguous argument to be the expression | Short boolean flags combine (`-rce`, `-sn`). Attached value forms such as `-o=json`, `-p=json`, and `-I=0` are accepted. In-place evaluation and serialization finish before a sibling temporary file is written and renamed over the source. A parse, filter, output-limit, write, or rename failure leaves the source unchanged. ## Compatibility boundary The expression language is jq, not mikefarah/yq's node/style language. Common selection, iteration, `select`, `map`, construction, reduction, and assignment filters work. mikefarah/yq-only operators for comments, styles, anchors, tags, file metadata, and cross-file evaluation are not implemented. YAML aliases, custom tags, non-string mapping keys, and non-finite numbers are rejected rather than expanding attacker-controlled graphs or silently losing information at the JSON-value boundary. Mapping keys are sorted deterministically. Comments, scalar style, and anchors are not retained after conversion. The parser follows YAML 1.1. TOML, CSV, and XML conversion are not part of this builtin; Bashkit's separate `tomlq` and `csv` helpers remain available for their existing narrow command surfaces. ## See also - [`jq_guide`](crate::jq_guide), the shared expression engine and its jq compatibility notes. - [`threat_model`](crate::threat_model), structured-input resource and information-disclosure controls. --- # Structured data Source: https://bashkit.sh/docs/structured-data.md Bashkit ships structured-data builtins for the formats scripts hit most often. `jq` and `yq` share one jq-compatible transformation engine; narrower helpers cover common CSV, JSON, and TOML operations. All read files or stdin. | Builtin | Format | Reach for it when | |---------|--------|-------------------| | [`jq`](../crates/bashkit/docs/jq.md) | JSON | You need real JSON transformation, filters, construction, reduction. | | [`yq`](../crates/bashkit/docs/yq.md) | YAML / JSON | You want the same jq expressions over YAML, conversion, or safe in-place updates. | | `json` | JSON | You want a quick `get` / `set` / `keys` / `length` without jq syntax. | | `csv` | CSV | Selecting columns, filtering rows, counting, sorting tabular data. | | `tomlq` | TOML | Reading a value out of `Cargo.toml`, `pyproject.toml`, etc. | ## csv Subcommands: `select`, `count`, `headers`, `filter`, `sort`. Use `-d` for a custom delimiter and `--no-header` for headerless data. ```bash csv select name,age data.csv # project columns csv filter age = 30 data.csv # rows where age == 30 csv sort name data.csv # sort by column csv count data.csv # row count csv headers data.csv # list column names echo "alice,30" | csv --no-header count ``` ## json A lighter alternative to `jq` for everyday access. Subcommands: `get`, `set`, `keys`, `length`, `type`, `format`, `pretty`. ```bash echo '{"a":1}' | json get .a # 1 echo '{"a":1}' | json set .b 2 # {"a":1,"b":2} echo '{"a":1,"b":2}' | json keys # a, b echo '[1,2,3]' | json length # 3 echo '{"a":1}' | json format # pretty-print ``` ## yq Use jq-style expressions over YAML or JSON. The `jq` Cargo feature enables it. YAML mapping keys are emitted in deterministic sorted order; source order, comments, styles, and anchors are not preserved. Custom tags, non-string keys, and non-finite numbers fail closed at the JSON-value boundary. ```bash yq '.server.port' config.yml yq '.items[] | select(.enabled) | .name' config.yml yq -o=json -I=0 config.yml yq -i '.server.port = 8080' config.yml ``` ## tomlq Query TOML by dot-separated path. `-r` emits raw (unquoted) string values. ```bash tomlq server.port config.toml tomlq -r dependencies.serde.version Cargo.toml cat config.toml | tomlq server.port ``` ## Composing them Because every builtin reads stdin, they pipe into each other and into the text tools: ```bash # CSV → JSON-ish summary csv select name,price products.csv | csv sort price # Pull a value out of config, then use it port=$(yq '.server.port' config.yml) echo "starting on $port" ``` ## See also - [jq builtin](../crates/bashkit/docs/jq.md), the full JSON query engine, with its own compatibility reference. - [yq builtin](../crates/bashkit/docs/yq.md), YAML/JSON flags, in-place behavior, and explicit gaps. - [Compatibility](../crates/bashkit/docs/compatibility.md), the complete builtin coverage matrix. - [Browse all builtins](/builtins), every registered command. --- # Custom builtins Source: https://bashkit.sh/docs/custom-builtins.md Bashkit supports registering custom builtin commands to extend the shell with domain-specific functionality. Custom builtins receive an execution-scoped context including arguments, environment variables, shell variables, and a revocable virtual-filesystem view. Retaining the VFS or extension handles is safe: access fails deterministically after that `exec*()` completes or is cancelled. **See also:** - [API Documentation](https://docs.rs/bashkit) - Full API reference - [Clap Builtins](./clap-builtins.md) - Derive parser structs for custom builtin args - [Hooks](./hooks.md) - Interceptor hooks for the execution pipeline - [Compatibility Reference](./compatibility.md) - Supported bash features - [Threat Model](./threat-model.md) - Security considerations - [Streaming commands](streaming.md) - Live byte handlers, async sinks and explicit capture ## Quick Start ```rust use bashkit::{Bash, Builtin, BuiltinContext, ExecResult, async_trait}; struct MyCommand; #[async_trait] impl Builtin for MyCommand { async fn execute(&self, ctx: BuiltinContext<'_>) -> bashkit::Result { let name = ctx.args.first().map(|s| s.as_str()).unwrap_or("World"); Ok(ExecResult::ok(format!("Hello, {}!\n", name))) } } #[tokio::main] async fn main() -> anyhow::Result<()> { let mut bash = Bash::builder() .builtin("greet", Box::new(MyCommand)) .build(); let result = bash.exec("greet Alice").await?; assert_eq!(result.stdout, "Hello, Alice!\n"); Ok(()) } ``` ## The Builtin Trait All custom builtins must implement the `Builtin` trait: ```rust,ignore #[async_trait] pub trait Builtin: Send + Sync { async fn execute(&self, ctx: BuiltinContext<'_>) -> Result; } ``` The trait is async-first (via `async_trait`) and requires `Send + Sync` for thread safety in async contexts. ## Execution Context The `BuiltinContext` provides access to the execution environment: ```rust,ignore pub struct BuiltinContext<'a> { /// Command arguments (not including the command name) pub args: &'a [String], /// Environment variables pub env: &'a HashMap, /// Shell variables (mutable) pub variables: &'a mut HashMap, /// Current working directory (mutable) pub cwd: &'a mut PathBuf, /// Virtual filesystem pub fs: Arc, /// Standard input (from pipeline) pub stdin: Option<&'a str>, } ``` ### Per-Execution Extensions For request-scoped data that should not live on the builtin itself, use `Bash::exec_with_extensions()` (or `exec_streaming_with_extensions()`) and read the value inside the builtin with `ctx.execution_extension::()`. ```rust,ignore use bashkit::{Bash, Builtin, BuiltinContext, ExecResult, ExecutionExtensions, async_trait}; struct RequestId; #[async_trait] impl Builtin for RequestId { async fn execute(&self, ctx: BuiltinContext<'_>) -> bashkit::Result { let req = ctx .execution_extension::() .and_then(|req| req.try_with(Clone::clone).ok()) .unwrap_or_else(|| "missing".to_string()); Ok(ExecResult::ok(format!("{req}\n"))) } } let bash = Bash::builder() .builtin("request-id", Box::new(RequestId)) .build(); let result = bash .exec_with_extensions( "request-id", ExecutionExtensions::new().with("req-123".to_string()), ) .await?; assert_eq!(result.stdout, "req-123\n"); ``` ## Extensions Use an `Extension` when one capability contributes multiple builtins. ```rust,ignore use bashkit::{Bash, Builtin, BuiltinContext, ExecResult, Extension, async_trait}; struct Hello; #[async_trait] impl Builtin for Hello { async fn execute(&self, _ctx: BuiltinContext<'_>) -> bashkit::Result { Ok(ExecResult::ok("hello\n".to_string())) } } struct MyExtension; impl Extension for MyExtension { fn builtins(&self) -> Vec<(String, Box)> { vec![("hello".to_string(), Box::new(Hello))] } } let mut bash = Bash::builder().extension(MyExtension).build(); let result = bash.exec("hello").await?; assert_eq!(result.stdout, "hello\n"); ``` The same extension can be added to `BashTool::builder().extension(...)`. `TypeScriptExtension` uses this model to register the embedded TypeScript/JavaScript builtins. ## Process-Local Host Calls Use an event-backed builtin when the host, not a `Builtin::execute` implementation, must receive a request and later supply its shell result. The execution remains parked in memory while the host performs the work. ```rust use bashkit::{Bash, ExecResult, ExecutionEvent}; # #[tokio::main] # async fn main() -> bashkit::Result<()> { let mut bash = Bash::builder() .host_call_builtin("lookup") .build(); let mut execution = bash.start_execution("lookup alice; echo done"); loop { match execution.next_event().await? { ExecutionEvent::HostCall(request) => { assert_eq!(request.command(), "lookup"); assert_eq!(request.args(), &["alice"]); execution.resume(request.id(), ExecResult::ok("Alice Example\n"))?; } ExecutionEvent::Complete(result) => { assert_eq!(result.stdout, "Alice Example\ndone\n"); break; } } } let _bash = execution.into_bash().expect("execution completed"); # Ok(()) # } ``` `HostCallRequest` owns the command arguments, exported environment, virtual working directory, and exact pipeline stdin. Resume with `ExecResult` to supply stdout, stderr, and exit status. `start_execution_with_options` preserves the normal streaming callback, extensions, positional parameters, and stdin. This is live-future suspension only. `ExecutionHandle` controls its `Bash` while the execution is active; after normal completion, call `into_bash()` to recover the session. Dropping a suspended handle drops the session so partially unwound interpreter state cannot be reused. Neither the handle nor a pending request is serializable. Calling an event-backed command through ordinary `exec()` fails immediately because no execution driver is present. Execution limits, including the wall-clock timeout, stay in force while a host call is parked. Where the target has a task spawner — any async runtime on native, `spawn_local` on JS-backed wasm — the execution runs on an independent task, so the deadline fires even if the host never polls the handle again. A non-JS wasm embedder has no executor to spawn onto, so there the handle keeps driving the future itself and the deadline is enforced on the host's next poll. Either way a timed-out execution drops its session: `into_bash()` recovers a completed run, never a timed-out one. One consequence of the spawned driver: a streaming callback passed through `start_execution_with_options` is invoked from the driver's task, not from the thread that calls `next_event()`. `OutputCallback` was always `Send + Sync`, so this is a thread-affinity change rather than a signature one — relevant only to a callback that assumed it ran on the caller's thread. ## BuiltinRegistry, Runtime-Mutable Builtins `BashBuilder::builtin` and `Extension` are both *build-time*: the set of builtins is frozen when `Bash::builder().build()` returns. For embedders that need to register or remove builtins after construction without rebuilding the interpreter, use `BuiltinRegistry`. ```rust,ignore use bashkit::{Bash, Builtin, BuiltinContext, BuiltinRegistry, ExecResult, async_trait}; use std::sync::Arc; struct Greet; #[async_trait] impl Builtin for Greet { async fn execute(&self, ctx: BuiltinContext<'_>) -> bashkit::Result { let who = ctx.args.first().map(String::as_str).unwrap_or("world"); Ok(ExecResult::ok(format!("hello {}\n", who))) } } // Keep a clone of the registry handle so the embedder can mutate it later. let registry = BuiltinRegistry::new(); let mut bash = Bash::builder() .builtin_registry(registry.clone()) .build(); // Run something first — VFS state accumulates. bash.exec("mkdir -p /scratch && echo seed > /scratch/seed.txt").await?; // Register a builtin after the interpreter is live. No rebuild, no VFS reset. registry.insert("greet", Arc::new(Greet)); let r = bash.exec("greet Alice").await?; assert_eq!(r.stdout, "hello Alice\n"); // Pre-existing files are still there. let r = bash.exec("cat /scratch/seed.txt").await?; assert_eq!(r.stdout, "seed\n"); ``` The registry handle is `Clone`; clones share the same underlying storage, so mutations made via any clone are visible to all others (including the interpreter that owns one). **Resolution order**: shell function → POSIX special builtin → registry entry → baked-in builtin → `$PATH`. Registry entries can override baked-in commands (e.g. wrap `cat` with tracing) but shell functions still win, matching standard bash precedence. The registry is host-owned: not part of interpreter state, so it survives `exec()` calls automatically and is not serialized by `Bash::snapshot()`. Re-attach the handle after restoring from a snapshot. `BuiltinRegistry::insert` uses the same execution-scoped facilities as builder builtins. A trusted embedder that deliberately needs a retained, session-lived VFS handle must opt in with `insert_trusted`; do not use it for tenant/plugin code. ### Arguments Arguments are passed as a slice of strings, excluding the command name itself: ```rust,ignore // For "mycommand arg1 arg2", ctx.args = ["arg1", "arg2"] let first_arg = ctx.args.first().map(|s| s.as_str()).unwrap_or("default"); ``` For custom builtins with richer flags, options, or subcommands, implement `ClapBuiltin` with a `#[derive(clap::Parser)]` argument type. See the `clap_builtins_guide` rustdoc module for tested examples. ### Environment Variables Read-only access to environment variables set via `BashBuilder::env()` or `export`: ```rust,ignore let home = ctx.env.get("HOME").map(|s| s.as_str()).unwrap_or("/"); ``` ### Shell Variables Mutable access to shell variables allows builtins to set variables: ```rust,ignore ctx.variables.insert("RESULT".to_string(), "computed_value".to_string()); ``` ### Filesystem Access The virtual filesystem supports all standard operations: ```rust,ignore // Read a file let content = ctx.fs.read_file(Path::new("/data/input.txt")).await?; // Write a file ctx.fs.write_file(Path::new("/output/result.txt"), b"output").await?; // Check existence if ctx.fs.exists(Path::new("/config")).await? { // ... } ``` ### Standard Input When the builtin is invoked in a pipeline, stdin contains the output from the previous command: ```rust,ignore // echo "hello" | mycommand let input = ctx.stdin.expect("pipeline input"); let exact_bytes = input.as_bytes(); let processed = input.text_lossy().to_uppercase(); // explicit text boundary ``` ## Return Values Builtins return `Result`: ```rust,ignore pub struct ExecResult { pub stdout: StreamData, pub stderr: StreamData, pub exit_code: i32, } ``` `StreamData` preserves exact bytes. Use `ExecResult::ok_bytes` for binary output, `as_bytes()`/`into_bytes()` for byte consumers, and `text()` or `text_lossy()` only for text-oriented consumers. Shell variables and command substitution are text-only; command substitution removes NUL bytes. Helper constructors: ```rust # use bashkit::ExecResult; // Success with output ExecResult::ok("output\n".to_string()); // Error with message and exit code ExecResult::err("error message\n".to_string(), 1); ``` Return `Ok(ExecResult::err(..))` for a failure the script should see as a non-zero exit. Returning `Err(..)` from a custom builtin aborts the whole `exec()` call; use it only for failures that must stop the script. ## Examples ### Database Query Builtin ```rust,ignore use bashkit::{Bash, Builtin, BuiltinContext, ExecResult, async_trait}; use sqlx::PgPool; use std::sync::Arc; struct Psql { pool: Arc, } #[async_trait] impl Builtin for Psql { async fn execute(&self, ctx: BuiltinContext<'_>) -> bashkit::Result { // Parse -c "query" argument let query = match ctx.args.iter().position(|a| a == "-c") { Some(i) => ctx.args.get(i + 1).map(|s| s.as_str()).unwrap_or(""), None => return Ok(ExecResult::err("Usage: psql -c 'query'\n".into(), 1)), }; // Execute query (simplified - real impl would format results) match sqlx::query(query).fetch_all(&*self.pool).await { Ok(rows) => Ok(ExecResult::ok(format!("{} rows\n", rows.len()))), Err(e) => Ok(ExecResult::err(format!("ERROR: {}\n", e), 1)), } } } // Usage let pool = Arc::new(PgPool::connect("postgres://...").await?); let mut bash = Bash::builder() .builtin("psql", Box::new(Psql { pool })) .build(); bash.exec("psql -c 'SELECT * FROM users'").await?; ``` ### HTTP Client Builtin ```rust,ignore struct HttpGet { client: reqwest::Client, } #[async_trait] impl Builtin for HttpGet { async fn execute(&self, ctx: BuiltinContext<'_>) -> bashkit::Result { let url = match ctx.args.first() { Some(url) => url, None => return Ok(ExecResult::err("Usage: httpget \n".into(), 1)), }; match self.client.get(url).send().await { Ok(resp) => { let body = resp.text().await.unwrap_or_default(); Ok(ExecResult::ok(body)) } Err(e) => Ok(ExecResult::err(format!("Error: {}\n", e), 1)), } } } ``` ### Overriding Default Builtins Custom builtins can override default builtins by using the same name: ```rust,no_run use bashkit::{Bash, Builtin, BuiltinContext, ExecResult, async_trait}; struct SecureEcho; #[async_trait] impl Builtin for SecureEcho { async fn execute(&self, ctx: BuiltinContext<'_>) -> bashkit::Result { // Redact sensitive patterns let output: Vec<_> = ctx.args.iter() .map(|s| if s.contains("password") { "[REDACTED]" } else { s.as_str() }) .collect(); Ok(ExecResult::ok(format!("{}\n", output.join(" ")))) } } # fn main() { let bash = Bash::builder() .builtin("echo", Box::new(SecureEcho)) // Overrides default echo .build(); # } ``` ## Best Practices 1. **Return proper exit codes**: Use 0 for success, non-zero for errors 2. **Include newlines**: Output should end with `\n` for proper formatting 3. **Handle missing args gracefully**: Provide usage messages for incorrect invocations 4. **Use stderr for errors**: Write error messages to `ExecResult::stderr` 5. **Keep builtins stateless when possible**: Use `Arc` for shared state that needs mutation ## Thread Safety The `Builtin` trait requires `Send + Sync`. For builtins with mutable state, use appropriate synchronization: ```rust use bashkit::{Builtin, BuiltinContext, ExecResult, async_trait}; use std::sync::Arc; struct Counter { count: Arc, } #[async_trait] impl Builtin for Counter { async fn execute(&self, _ctx: BuiltinContext<'_>) -> bashkit::Result { let n = self.count.fetch_add(1, std::sync::atomic::Ordering::SeqCst); Ok(ExecResult::ok(format!("{}\n", n))) } } ``` ## Integration with Scripts Custom builtins integrate smoothly with bash scripting: ```bash # Variables work NAME="Alice" greet $NAME # Pipelines work echo "hello world" | upper | head -1 # Conditionals work if mycheck; then echo "passed" else echo "failed" fi # Loops work for item in a b c; do process $item done ``` --- # Custom builtins (JavaScript) Source: https://bashkit.sh/docs/custom-builtins-js.md Register your own commands as bash builtins. They behave like baked-in commands: invoke them by name, pipe data through them, redirect their output into the virtual filesystem. They share the interpreter's VFS and shell state, so a builtin's output written to `/scratch/out.json` is still there in the next `execute()` call. This page covers the Node bindings (`@everruns/bashkit`). The Rust core exposes the same capability via `BashBuilder::builtin_registry`; see the [bashkit rustdoc guide](https://docs.rs/bashkit/latest/bashkit/custom_builtins_guide/) for the Rust API and `crates/bashkit/tests/builtin_registry_tests.rs` for worked examples. For live byte handlers with awaited writes, explicit capture and async sinks, see [streaming commands](streaming.md). ## Quick start ```typescript import { Bash } from "@everruns/bashkit"; const bash = new Bash({ customBuiltins: { greet: (ctx) => `hello ${ctx.argv[0] ?? "world"}\n`, "get-order": async (ctx) => { const order = await fetchOrder(ctx.argv[0]); return JSON.stringify(order) + "\n"; }, }, }); await bash.execute("mkdir -p /scratch"); await bash.execute("get-order 42 > /scratch/order.json"); console.log((await bash.execute("cat /scratch/order.json")).stdout); ``` Two ways to register: | API | When | Notes | |-----|------|-------| | `new Bash({ customBuiltins: {...} })` | At construction | Convenient for a fixed set of builtins. | | `bash.addBuiltin(name, callback)` | Any time after | Safe to call after `execute()` has accumulated state, the interpreter is **not** rebuilt and the VFS stays intact. | | `bash.removeBuiltin(name)` | Any time after | Subsequent invocations fall through to baked-in builtins / `$PATH`. | Same API on `BashTool`. ## The callback contract A callback receives one argument, a `BuiltinContext` snapshot of shell state at invocation time, and returns the stdout to emit: ```typescript import type { BuiltinContext, BuiltinCallback } from "@everruns/bashkit"; interface BuiltinContext { readonly name: string; // command name as invoked readonly argv: string[]; // args, not including the name readonly stdin: string | null; // piped input, null if no pipe readonly env: Record; // exported env vars readonly cwd: string; // current working directory } type BuiltinCallback = (ctx: BuiltinContext) => string | Promise; ``` Sync (`string`) and async (`Promise`) returns are both supported. Internally, every return is wrapped with `Promise.resolve(...)` so the Rust adapter handles them uniformly. The return value is treated as stdout. To emit a specific exit code or stderr, throw, exceptions become stderr with exit code 1, like a real failing command: ```typescript const bash = new Bash({ customBuiltins: { fail: () => { throw new Error("nope"); // → stderr: "GenericFailure, Error: nope", exit 1 }, "async-fail": async () => { throw new Error("async nope"); // same, with the async-rejection text }, }, }); ``` ## Sync vs async, and why you can't use `executeSync()` Custom builtins are dispatched over NAPI's threadsafe-function bridge, which schedules callbacks on the JS event loop. That means **the JS event loop must be free to dispatch them**. `bash.executeSync()` blocks the JS event loop synchronously while the interpreter runs. If the script invokes a custom builtin, the dispatch never gets a chance to fire, the call deadlocks. > **Always use `await bash.execute(...)`** when custom builtins are > registered. This matches `ScriptedTool`'s constraint. A runtime guardrail to fail fast instead of deadlocking is tracked in [#1725](https://github.com/everruns/bashkit/issues/1725). ## Persistent VFS The interpreter's virtual filesystem persists across `execute()` calls, *including* the calls inside which a custom builtin wrote files. This is the main difference from `ScriptedTool`, where each script gets a fresh interpreter: ```typescript const bash = new Bash({ customBuiltins: { log: (ctx) => `${new Date().toISOString()} ${ctx.argv.join(" ")}\n`, }, }); // Each call appends to the same virtual file. await bash.execute("log started >> /var/log/app.log"); await bash.execute("log processed 42 >> /var/log/app.log"); await bash.execute("log done >> /var/log/app.log"); console.log((await bash.execute("cat /var/log/app.log")).stdout); // 2026-05-24T... started // 2026-05-24T... processed 42 // 2026-05-24T... done ``` ## Override precedence Command resolution order in the interpreter: 1. Shell functions defined in the script 2. POSIX special builtins (`exec`, `set`, `:`, `eval`, …) 3. **Custom builtins** (`customBuiltins` + `addBuiltin`) 4. Baked-in builtins (`cat`, `ls`, `grep`, …) 5. Scripts on `$PATH` So custom builtins **can** override baked-in commands (e.g. wrap `cat` with tracing), but a shell function defined in the script still wins: ```typescript const bash = new Bash({ customBuiltins: { thing: () => "from-builtin\n", }, }); // Custom builtin wins over the baked-in (no baked-in `thing` anyway) console.log((await bash.execute("thing")).stdout); // from-builtin // Shell function wins over the custom builtin const r = await bash.execute( "thing() { printf 'from-function\\n'; }\nthing", ); console.log(r.stdout); // from-function ``` `command -v thing` and `command -V thing` report custom builtins as builtins. ## Lifecycle | Operation | Custom builtins | |-----------|----------------| | `bash.reset()` | **Preserved.** The registry is host-side; only interpreter shell state and VFS are reset. | | `bash.snapshot()` / `restoreSnapshot()` | **Not preserved.** Snapshots contain interpreter state only. Re-pass `customBuiltins` (or call `addBuiltin`) after restoring. | | `Bash.fromSnapshot(data, options)` | Same as `restoreSnapshot`: pass `customBuiltins` in the `options`. | | `bash.addBuiltin` / `removeBuiltin` | Take effect immediately for the next `execute()`. No interpreter rebuild. | ## `BashTool` `BashTool` has the same API, useful when exposing a sandboxed shell to an LLM as a tool. Custom builtins augment the tool's command surface: ```typescript import { BashTool } from "@everruns/bashkit"; const tool = new BashTool({ customBuiltins: { "get-weather": async (ctx) => { const city = ctx.argv[0] ?? "unknown"; return JSON.stringify({ city, temp: 72, sky: "clear" }) + "\n"; }, }, }); const r = await tool.execute( "get-weather 'San Francisco' | jq -r '.temp'", ); // 72 ``` ## Common patterns ### Wrap a host API Expose a callable that pulls from your backend, leaves the result in the VFS, and lets the LLM (or downstream shell logic) process it with normal shell tools: ```typescript const bash = new Bash({ customBuiltins: { "search-tickets": async (ctx) => { const tickets = await db.tickets.search(ctx.argv[0]); return tickets.map((t) => `${t.id}\t${t.title}`).join("\n") + "\n"; }, }, }); await bash.execute( "search-tickets 'auth bug' > /tmp/results.tsv && wc -l < /tmp/results.tsv", ); ``` ### Stage-based pipelines Custom builtins can read piped stdin and emit transformed output, chain them like any other bash command: ```typescript const bash = new Bash({ customBuiltins: { parse: (ctx) => JSON.parse(ctx.stdin ?? "{}").value ?? "", sign: async (ctx) => signWithKms(ctx.stdin?.trim() ?? ""), }, }); await bash.execute("cat /in/req.json | parse | sign > /out/signed.txt"); ``` ### Override for tracing or recording Wrap a baked-in builtin to log every invocation while preserving original behavior (call into the bashkit interpreter via the parent if you need the original result, for full override+passthrough see the Rust API): ```typescript const calls: string[] = []; const bash = new Bash({ customBuiltins: { cat: (ctx) => { calls.push(`cat ${ctx.argv.join(" ")}`); // Read files directly via bash.fs() and return contents return ctx.argv .map((p) => bash.readFile(p)) .join(""); }, }, }); ``` ## See also - Example script: [`examples/custom_builtins.mjs`](https://github.com/everruns/bashkit/blob/main/examples/custom_builtins.mjs), runnable, asserts at every step, exercised in CI. - API reference: [`@everruns/bashkit` README](https://github.com/everruns/bashkit/blob/main/crates/bashkit-js/README.md), option/method signatures. - Rust core: [`bashkit::BuiltinRegistry`](https://docs.rs/bashkit/latest/bashkit/struct.BuiltinRegistry.html), [`BashBuilder::builtin_registry`](https://docs.rs/bashkit/latest/bashkit/struct.BashBuilder.html#method.builtin_registry). - Design rationale: PR [#1721](https://github.com/everruns/bashkit/pull/1721). - Python parity: tracked in [#1724](https://github.com/everruns/bashkit/issues/1724). - `executeSync` deadlock guardrail: tracked in [#1725](https://github.com/everruns/bashkit/issues/1725). --- # Streaming commands and execution Source: https://bashkit.sh/docs/streaming.md Read command input and deliver output while a script runs. Streaming commands work with shell pipes and redirections, so you can connect host APIs and async services to commands such as `cat` and `grep`. A handler reads bytes from `stdin`, awaits writes to `stdout` and `stderr`, and returns an exit status. Choose output sinks for live delivery, bounded capture for a final result, or both. See [runtime support](#runtime-support) for the APIs available on each surface. ## Rust Implement `Command` and register it with `BashBuilder::command`: ```rust use bashkit::{async_trait, Bash, Command, CommandContext, CommandStatus}; struct Copy; #[async_trait] impl Command for Copy { async fn execute(&self, ctx: CommandContext<'_>) -> bashkit::Result { while let Some(bytes) = ctx.stdin.read_chunk().await? { ctx.stdout.write_all(&bytes).await?; } Ok(CommandStatus::success()) } } # #[tokio::main] # async fn main() -> bashkit::Result<()> { let mut bash = Bash::builder().command("copy", Copy).build(); let mut delivered = Vec::new(); let result = bash.execute("printf hello | copy") .stdout(&mut delivered) .capture(1024) .await?; assert_eq!(delivered, b"hello"); assert_eq!(result.stdout.as_bytes(), b"hello"); assert_eq!(result.exit_code, 0); # Ok(()) # } ``` `.stdout(&mut writer)` and `.stderr(&mut writer)` accept Tokio async byte writers. The example uses a `Vec` to collect delivered bytes in the host; replace it with your destination's async writer to forward output. For async callbacks, use `.stdout_fn(callback)` and `.stderr_fn(callback)`, which await callbacks returning `Result<()>`. `.capture(n)` retains at most `n` bytes per stream in the final `ExecResult`. Without capture, the result's output fields are empty. Output without a sink or capture is drained and discarded. Use `as_bytes()` on captured `StreamData` for exact bytes. Pass stdin, invocation arguments or request-scoped extensions through `.options(ExecOptions)`. See the [Rust API reference](https://docs.rs/bashkit/latest/bashkit/struct.Execution.html) for the full builder API. ## Python Install the [native Python package](start-python.md), then register an async callable with `add_command`: ```python import asyncio from bashkit import Bash, CommandStatus async def copy(ctx): async for chunk in ctx.stdin: await ctx.stdout.write(chunk) return CommandStatus() async def main(): shell = Bash() shell.add_command("copy", copy) async def show_output(chunk: bytes): print(repr(chunk), flush=True) result = await shell.execute( "printf hello | copy", stdout=show_output, capture=1024, ) assert result.stdout_bytes == b"hello" assert result.exit_code == 0 asyncio.run(main()) ``` `BashTool` supports the same registration and execution options. Handlers return `CommandStatus` or an integer exit status. Read input with `async for` or `await ctx.stdin.read_chunk()`, which returns `bytes` or `None` at EOF. Output sinks must be async callables accepting `bytes`; await your destination inside the callback. Use `stdout_bytes` and `stderr_bytes` for exact captured output. The `stdout` and `stderr` text properties use lossy decoding. An ordinary `execute(script)` captures bounded output. Passing `stdout` or `stderr` selects sink delivery; add `capture=n` to retain a final copy as well. Live commands and byte sinks require async `execute()`; `execute_sync()` cannot drive them. See the [Python API reference](https://bashkit.sh/api/python/). ## JavaScript Install the [native JavaScript package](start-node.md) for Node, Bun or Deno: ```javascript import { Bash } from "@everruns/bashkit"; const shell = new Bash({ commands: { async copy(ctx) { for await (const bytes of ctx.stdin) { await ctx.stdout.write(bytes); } return { exitCode: 0 }; }, }, }); const result = await shell.execute("printf hello | copy", { capture: 1024, async stdout(bytes) { console.log(bytes); // Uint8Array }, }); console.assert(result.stdout === "hello"); console.assert(result.exitCode === 0); ``` `addCommand(name, callback)` registers a handler after construction. Handlers return a Promise resolving to an integer or `{exitCode}`. Native `BashTool` supports the same options. Configured [Python and TypeScript host functions](start-node.md#python-and-typescript-host-functions) remain available with byte sinks and explicit capture. Their handlers retain the caller's async context and receive an execution-scoped abort signal. Sinks receive `Uint8Array` and may return `void` or a Promise; return the Promise when your destination needs time to consume a chunk. Use `stdin.readChunk()` to read a chunk or `null` at EOF, and `stdoutBytes`/`stderrBytes` for exact captured output. Ordinary `execute(script)` captures bounded output; sink delivery retains a final copy only when `capture` is specified. Live commands and byte sinks require async `execute()`. See the [TypeScript API reference](https://bashkit.sh/api/typescript/). ### Browser and worker The [browser package](start-browser.md), `@everruns/bashkit-wasm`, exposes the same `commands`, `addCommand` and byte-sink APIs. Initialize it before creating a shell: ```javascript import { Bash, initBashkit } from "@everruns/bashkit-wasm"; await initBashkit(); const shell = new Bash(); const result = await shell.execute("printf hello", { capture: 1024, async stdout(bytes) { console.log(bytes); }, }); console.assert(result.stdout === "hello"); ``` This works on the main thread and in a module worker without `SharedArrayBuffer` or cross-origin isolation headers. Use `execute()` for live handlers and sinks; `executeSync()` cannot wait for host Promises. ## Capture, limits and flow control Capture and delivery are separate choices: | Output policy | Sink receives bytes while running | Final output retained | | --- | --- | --- | | Capture only | No | Up to the capture limit per stream | | Sink only | Yes | None | | Sink and capture | Yes | Up to the capture limit per stream | | Neither, with Rust `Bash::execute` | No; output is discarded | None | Python and JavaScript `execute(script)` default to bounded capture. Rust `Bash::execute(script)` defaults to discard; use `.capture(n)` when you need a final output value. Rust `exec()` remains the buffered convenience operation. Capture is clamped to the sandbox's stdout/stderr byte limits. A smaller capture limit does not shorten sink delivery. The sandbox limits still cap total output: bytes beyond those limits are drained and discarded, and the result's truncation flags are set. Capture truncation also sets those flags. See [sandbox configuration](configuration.md) for resource limits. Live pipes hold at most 4 KiB of queued bytes. The engine awaits each sink callback before delivering its next chunk. Await writes inside handlers and await downstream work inside sinks to preserve backpressure. Chunks are byte segments, not complete lines or UTF-8 characters; use a streaming decoder when converting text across chunk boundaries. Memory retained by your destination is separate from engine capture. A growing host array or queue can still retain the whole output even when capture is disabled. Rust async writers flush at completion; flush inside a byte callback when a buffered destination must make each chunk visible immediately. Streaming builtin readers charge input to the request's aggregate byte budget. Record buffers and whole-input collectors also reserve intermediate memory before growing. A very long line can therefore fail the memory budget even when pipe queues remain within 4 KiB; disabling capture does not disable these resource limits. Plain `cat`, line-printing `grep`, and nonsqueezing `tr` preserve live delivery through descriptor duplication and file redirection, including `2>&1`. Redirected files are opened once before execution. Options that collect or summarize output can still delay delivery until their result is available. Other utilities, including `sed`, `awk`, and `tee`, retain their buffered execution paths. Process substitutions `<(...)` and `>(...)` preserve raw bytes, including NUL and invalid UTF-8, but still buffer rather than running both sides as live tasks. Command substitution `$(...)` remains a text boundary and removes NUL. Background jobs remain scoped to one execution and buffer output until completion. Final job output reaches live sinks before execution completes, including when the script does not explicitly call `wait`. ## Routing, errors and cancellation A command context provides its name, expanded arguments (`args` in Rust, `argv` in Python/JS), exported environment, cwd and a request-scoped filesystem, along with its byte reader and writers. Use those capabilities inside handlers; do not call execution or state methods on the same shell from its callbacks. Shell redirects apply to live command output. Files are opened before handler invocation, and writes are visible before the handler completes. Redirection order matters: ```bash command >/out 2>&1 # stdout and stderr go to /out command 2>&1 >/out # stderr keeps the original stdout destination ``` Separate stdout/stderr sinks have no shared ordering guarantee. Merge streams with `2>&1` when you need one destination in write order. Nonzero command status is a normal shell result. In Rust, an unhandled broken-pipe write produces status 141; a handler that catches the error can return its own status. Sink failure stops the execution and drops pending engine work. Execution errors are reported as follows: | API | Sink, cancellation or resource-limit failure | | --- | --- | | Rust | `Err` | | Native Python and JavaScript | The result's `error` field | | JS/WASM | Rejected execution Promise | Deadlines cover pending sinks as well as engine work. In Rust, use the session's `cancellation_token()` or `.cancel_with(token)` on the execution builder. In Python, call `cancel()` and clear it with `clear_cancel()` before reusing the shell. In JavaScript, pass `signal: controller.signal` in execute options to interrupt pending delivery. Signal cancellation clears after the call finishes; explicit `cancel()` remains set until `clearCancel()`. Command I/O and filesystem capabilities expire when their execution ends, including cancellation or sink failure. Retaining a context does not authorize later I/O. Cancellation does not roll back host side effects already performed. ## Runtime support | Surface | Streaming API | Limitations | | --- | --- | --- | | Rust library | Live `Command`, async writers/callbacks, bounded capture | Async execution required. | | Native Python | `Bash`/`BashTool.add_command`, async byte sinks and capture | Native asyncio API; unavailable in Pyodide. | | Native JavaScript: Node, Bun, Deno | `Bash`/`BashTool.addCommand`, byte sinks and capture | Async `execute()` required. | | Browser and module worker | `@everruns/bashkit-wasm` commands, sinks and capture | Async `execute()` required; sync cannot drive host Promises. | | Other JS/edge hosts | Same WASM API where host facilities are available | Streaming support depends on the host's timers and async callback support. | | CLI and interactive shell | Live stdout/stderr delivery | Host stdin is still read within its existing input cap. | | In-process Terminal | Terminal polling, screen and output APIs | General byte-sink options and the `commands` constructor option are not Terminal APIs. | | Rust tools and scripted tools | Existing informational output streams and final tool results | Existing tool queues do not provide end-to-end bounded backpressure. Native Python/JS `BashTool.execute` byte sinks are supported separately. | | Native C ABI | `bashkit_execute_streaming`, byte callbacks and optional capture | Callbacks are synchronous; no custom-command registration. See [C API](c-api.md#streaming-output). | | Pyodide / JupyterLite | Existing synchronous execution | Async commands and byte sinks are unavailable. | | WASI libraries | Shared core library | No published streaming embedding API. | | Non-JS component / Hyperlight | Experimental component API | No streaming interface across the component boundary. | | Native npm package's threaded WASI/browser fallback | Existing generated addon API | Public command/sink wrapper helpers are not exposed through its browser entry. Use `@everruns/bashkit-wasm` for these APIs. | ## Migration and limitations For a handler that needs live I/O, replace returned output strings with awaited writes, buffered stdin with reader iteration, and the returned output value with an exit status. Buffered `Builtin` callbacks remain available for whole-input operations and supported synchronous execution paths. If you use Rust's script analysis API, update the type that describes where a command appears in a script (for example, inside a function or command substitution): ```rust // Before use bashkit::CommandContext; // After use bashkit::CommandPlacement; ``` Update references such as `CommandContext::Direct` to `CommandPlacement::Direct` as well. Imports from `bashkit::analysis` also use `CommandPlacement`. `bashkit::CommandContext` now names the context passed to live command handlers, including their stdin/stdout/stderr. This rename only affects Rust code using script analysis; see the [script analysis guide](../crates/bashkit/docs/script-analysis.md). The new C streaming entry point is additive within ABI v1. Python `on_output`, native JS `onOutput` and WASM `executeWithOutput` remain synchronous observers with buffered final results. Use the new byte sinks for awaitable delivery. Native Python/JS execution rejects combining these observers with explicit capture/sink options; Rust's execution builder also rejects an `ExecOptions` output observer. Whole-output `after_tool`/`after_exec` hooks cannot inspect or replace bytes that have already been delivered. Live execution rejects these hooks; use buffered execution for whole-output transformations. Streaming is supported by live handlers and selected builtin paths, not every shell operation. Whole-input utilities, command substitution `$(...)`, process substitutions `<(...)`/`>(...)`, job results and text observers can still retain complete values within existing limits. A live handler does not make a buffered downstream utility incremental. ## See also - [Rust custom builtins](../crates/bashkit/docs/custom_builtins.md) - [JavaScript custom builtins](custom_builtins_js.md) - [Sandbox configuration](configuration.md) - [LLM tools](llm-tools.md) - [Terminal](terminal.md) - [C API](c-api.md) - [Browser quickstart](start-browser.md) - [Pyodide quickstart](start-pyodide.md) --- # Clap builtins Source: https://bashkit.sh/docs/clap-builtins.md `ClapBuiltin` lets Rust applications define custom Bashkit commands with `#[derive(clap::Parser)]` argument structs. Handlers receive a `BashkitContext` that captures stdout, stderr, and exit code for the virtual shell. `clap` is an unconditional dependency of `bashkit`, so this trait is always available. ## Basic Usage ```rust use bashkit::clap::Parser; use bashkit::{Bash, BashkitContext, ClapBuiltin, async_trait}; #[derive(Parser)] #[command(name = "greet", about = "Print a greeting")] struct GreetArgs { #[arg(short, long, default_value = "World")] name: String, #[arg(short, long)] shout: bool, } struct Greet; #[async_trait] impl ClapBuiltin for Greet { type Args = GreetArgs; async fn execute_clap( &self, args: Self::Args, ctx: &mut BashkitContext<'_>, ) -> bashkit::Result<()> { let greeting = format!("Hello, {}!", args.name); let greeting = if args.shout { greeting.to_uppercase() } else { greeting }; ctx.write_stdout(format!("{greeting}\n")); Ok(()) } } #[tokio::main] async fn main() -> bashkit::Result<()> { let mut bash = Bash::builder().builtin("greet", Box::new(Greet)).build(); let result = bash.exec("greet --name Alice --shout").await?; assert_eq!(result.stdout, "HELLO, ALICE!\n"); let help = bash.exec("greet --help").await?; assert_eq!(help.exit_code, 0); assert!(help.stdout.contains("Usage: greet")); assert!(help.stderr.is_empty()); let error = bash.exec("greet --unknown").await?; assert_eq!(error.exit_code, 2); assert!(error.stderr.contains("unexpected argument")); Ok(()) } ``` ## Subcommands Use normal clap subcommands for nested command surfaces. ```rust use bashkit::clap::{Parser, Subcommand}; use bashkit::{Bash, BashkitContext, ClapBuiltin, async_trait}; #[derive(Parser)] #[command(name = "math")] struct MathArgs { #[command(subcommand)] command: MathCommand, } #[derive(Subcommand)] enum MathCommand { Add { left: i64, right: i64 }, StdinLen, } struct Math; #[async_trait] impl ClapBuiltin for Math { type Args = MathArgs; async fn execute_clap( &self, args: Self::Args, ctx: &mut BashkitContext<'_>, ) -> bashkit::Result<()> { let value = match args.command { MathCommand::Add { left, right } => left + right, MathCommand::StdinLen => ctx.stdin().unwrap_or("").len() as i64, }; ctx.write_stdout(format!("{value}\n")); Ok(()) } } #[tokio::main] async fn main() -> bashkit::Result<()> { let mut bash = Bash::builder().builtin("math", Box::new(Math)).build(); let sum = bash.exec("math add 20 22").await?; assert_eq!(sum.stdout, "42\n"); let stdin_len = bash.exec("printf abc | math stdin-len").await?; assert_eq!(stdin_len.stdout, "3\n"); Ok(()) } ``` ## Behavior - The clap parser receives only the command arguments, not the shell command name from the script. - `BashkitContext::write_stdout()` and `write_stderr()` append virtual command output; direct `println!`/`eprintln!` writes to the host process instead. - Set non-zero command status with `ctx.set_exit_code(code)` or `ctx.fail(message, code)`. - `--help` and `--version` return exit code `0` with clap output on stdout. - Parse failures return clap's exit code, usually `2`, with diagnostics on stderr. - Diagnostics are capped at 1 KB so custom builtins keep Bashkit's builtin error-output safety guarantees. ## See Also - [`crates/bashkit/examples/clap_builtin.rs`](../examples/clap_builtin.rs) - [`crates/bashkit/examples/clap_builtin_subcommands.rs`](../examples/clap_builtin_subcommands.rs) - [`crates/bashkit/docs/custom_builtins.md`](./custom_builtins.md) --- # Hooks Source: https://bashkit.sh/docs/hooks.md Bashkit provides an interceptor hook system that lets you observe, modify, or cancel operations at key points in the execution pipeline. Hooks are registered at build time via [`BashBuilder`] and are immutable after construction. **See also:** - [Custom Builtins](./custom_builtins.md) - Extending the shell with commands - [Threat Model](./threat-model.md) - Security considerations - [API Documentation](https://docs.rs/bashkit) - Full API reference ## Quick Start ```rust use bashkit::{Bash, hooks::{HookAction, ExecInput}}; # #[tokio::main] # async fn main() -> bashkit::Result<()> { let mut bash = Bash::builder() .before_exec(Box::new(|input: ExecInput| { println!("about to run: {}", input.script); HookAction::Continue(input) })) .build(); let result = bash.exec("echo hello").await?; assert_eq!(result.stdout, "hello\n"); # Ok(()) # } ``` ## How Hooks Work Every hook is an **interceptor**: a closure that receives owned data and must return a [`HookAction`]: - **`HookAction::Continue(value)`**: proceed with the (possibly modified) value - **`HookAction::Cancel(reason)`**: abort the operation Multiple hooks of the same type run in registration order. If any hook returns `Cancel`, later hooks are skipped and the operation is aborted. Hooks have **zero overhead** when none are registered, the interpreter checks `Vec::is_empty()` and skips the hook path entirely. ## Hook Types | Hook | Fires | Can modify | Can cancel | |------|-------|------------|------------| | `before_exec` | Before script execution | Script text | Yes | | `after_exec` | After script execution | stdout, stderr, exit code | No* | | `before_tool` | Before a builtin command runs | Tool name, args | Yes | | `after_tool` | After a builtin command completes | Tool name, stdout, exit code | No* | | `on_exit` | When `exit` builtin runs | Exit code | Yes | | `on_error` | On interpreter error | Error message | No* | | `before_http` | Before HTTP request (after allowlist) | URL, method, headers | Yes | | `after_http` | After HTTP response received | URL, status, headers | No* | *These hooks receive `Continue`/`Cancel` for API consistency, but cancelling an already-completed operation is a no-op in practice. ## Execution Hooks ### `before_exec`, Modify or Block Scripts Fires before each `bash.exec()` call. The hook receives an [`ExecInput`] with the script text and can rewrite or cancel it. ```rust use bashkit::{Bash, hooks::{HookAction, ExecInput}}; # #[tokio::main] # async fn main() -> bashkit::Result<()> { let mut bash = Bash::builder() .before_exec(Box::new(|mut input: ExecInput| { // Block dangerous commands if input.script.contains("rm -rf") { return HookAction::Cancel("destructive command blocked".into()); } // Rewrite scripts on the fly input.script = input.script.replace("world", "hooks"); HookAction::Continue(input) })) .build(); let result = bash.exec("echo hello world").await?; assert_eq!(result.stdout, "hello hooks\n"); // Cancelled scripts return exit code 1 let result = bash.exec("rm -rf /").await?; assert_eq!(result.exit_code, 1); # Ok(()) # } ``` ### `after_exec`, Observe Results Fires after script execution completes. Useful for logging, metrics, or post-processing. ```rust use bashkit::{Bash, hooks::{HookAction, ExecOutput}}; use std::sync::{Arc, Mutex}; # #[tokio::main] # async fn main() -> bashkit::Result<()> { let log = Arc::new(Mutex::new(Vec::new())); let log_clone = log.clone(); let mut bash = Bash::builder() .after_exec(Box::new(move |output: ExecOutput| { log_clone.lock().unwrap().push(format!( "[exit {}] {}", output.exit_code, output.script, )); HookAction::Continue(output) })) .build(); bash.exec("echo first").await?; bash.exec("echo second").await?; let entries = log.lock().unwrap(); assert_eq!(entries.len(), 2); assert!(entries[0].contains("first")); # Ok(()) # } ``` ## Tool Hooks Tool hooks fire around **registered builtin commands** (e.g. `echo`, `cat`, `grep`). They do not fire for shell-level special builtins like `declare`, `local`, or `export`. ### `before_tool`, Intercept Commands ```rust use bashkit::{Bash, hooks::{HookAction, ToolEvent}}; # #[tokio::main] # async fn main() -> bashkit::Result<()> { let mut bash = Bash::builder() .before_tool(Box::new(|event: ToolEvent| { // Block specific commands if event.name == "curl" { return HookAction::Cancel("curl is disabled".into()); } HookAction::Continue(event) })) .build(); let result = bash.exec("echo allowed").await?; assert_eq!(result.exit_code, 0); # Ok(()) # } ``` ### `after_tool`, Audit Command Results ```rust use bashkit::{Bash, hooks::{HookAction, ToolResult}}; use std::sync::{Arc, Mutex}; # #[tokio::main] # async fn main() -> bashkit::Result<()> { let results = Arc::new(Mutex::new(Vec::new())); let results_clone = results.clone(); let mut bash = Bash::builder() .after_tool(Box::new(move |result: ToolResult| { results_clone.lock().unwrap().push(( result.name.clone(), result.exit_code, )); HookAction::Continue(result) })) .build(); bash.exec("echo hello").await?; let captured = results.lock().unwrap(); assert_eq!(captured[0].0, "echo"); assert_eq!(captured[0].1, 0); # Ok(()) # } ``` ## Lifecycle Hooks ### `on_exit`, Handle Script Exit Fires when the `exit` builtin is called. Can modify the exit code or prevent the exit entirely. ```rust use bashkit::{Bash, hooks::{HookAction, ExitEvent}}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; # fn main() { let exited = Arc::new(AtomicBool::new(false)); let flag = exited.clone(); let bash = Bash::builder() .on_exit(Box::new(move |event: ExitEvent| { flag.store(true, Ordering::Relaxed); HookAction::Continue(event) })) .build(); # } ``` ### `on_error`, Handle Errors Fires when the interpreter encounters an error (parse errors, runtime errors). ```rust use bashkit::{Bash, hooks::{HookAction, ErrorEvent}}; use std::sync::{Arc, Mutex}; # fn main() { let errors = Arc::new(Mutex::new(Vec::new())); let errors_clone = errors.clone(); let bash = Bash::builder() .on_error(Box::new(move |event: ErrorEvent| { errors_clone.lock().unwrap().push(event.message.clone()); HookAction::Continue(event) })) .build(); # } ``` ## HTTP Hooks HTTP hooks require the `http_client` feature (enabled by default). They fire around HTTP requests made by `curl`, `wget`, and `http` builtins. HTTP hooks fire **after** the [`NetworkAllowlist`] check, so the security boundary stays in bashkit, hooks cannot bypass the allowlist. ### `before_http`, Filter or Modify Requests ```rust,ignore use bashkit::{Bash, NetworkAllowlist, hooks::{HookAction, HttpRequestEvent}}; # fn main() { let bash = Bash::builder() .network(NetworkAllowlist::allow_all()) .before_http(Box::new(|mut req: HttpRequestEvent| { // Add a custom header to all requests req.headers.push(("X-Source".into(), "bashkit".into())); // Block requests to certain domains if req.url.contains("blocked.example.com") { return HookAction::Cancel("blocked by policy".into()); } HookAction::Continue(req) })) .build(); # } ``` ### `after_http`, Observe Responses ```rust,ignore use bashkit::{Bash, NetworkAllowlist, hooks::{HookAction, HttpResponseEvent}}; use std::sync::{Arc, Mutex}; # fn main() { let responses = Arc::new(Mutex::new(Vec::new())); let responses_clone = responses.clone(); let bash = Bash::builder() .network(NetworkAllowlist::allow_all()) .after_http(Box::new(move |resp: HttpResponseEvent| { responses_clone.lock().unwrap().push(( resp.url.clone(), resp.status, )); HookAction::Continue(resp) })) .build(); # } ``` ## Chaining Multiple Hooks Multiple hooks of the same type run in registration order. Each hook receives the output of the previous one: ```rust use bashkit::{Bash, hooks::{HookAction, ExecInput}}; # #[tokio::main] # async fn main() -> bashkit::Result<()> { let mut bash = Bash::builder() .before_exec(Box::new(|mut input: ExecInput| { input.script = input.script.replace("world", "hooks"); HookAction::Continue(input) })) .before_exec(Box::new(|mut input: ExecInput| { input.script = input.script.replace("hello", "greetings"); HookAction::Continue(input) })) .build(); let result = bash.exec("echo hello world").await?; assert_eq!(result.stdout, "greetings hooks\n"); # Ok(()) # } ``` If any hook in the chain returns `Cancel`, the remaining hooks are skipped: ```rust use bashkit::{Bash, hooks::{HookAction, ExecInput}}; # #[tokio::main] # async fn main() -> bashkit::Result<()> { let mut bash = Bash::builder() .before_exec(Box::new(|_input: ExecInput| { HookAction::Cancel("first hook cancelled".into()) })) .before_exec(Box::new(|input: ExecInput| { // This hook never runs HookAction::Continue(input) })) .build(); let result = bash.exec("echo never runs").await?; assert_eq!(result.exit_code, 1); # Ok(()) # } ``` ## Event Payloads | Payload | Fields | |---------|--------| | [`ExecInput`] | `script: String` | | [`ExecOutput`] | `script: String`, `stdout: String`, `stderr: String`, `exit_code: i32` | | [`ToolEvent`] | `name: String`, `args: Vec` | | [`ToolResult`] | `name: String`, `stdout: String`, `exit_code: i32` | | [`ExitEvent`] | `code: i32` | | [`ErrorEvent`] | `message: String` | | [`HttpRequestEvent`] | `method: String`, `url: String`, `headers: Vec<(String, String)>` | | [`HttpResponseEvent`] | `url: String`, `status: u16`, `headers: Vec<(String, String)>` | ## Thread Safety Hook closures must be `Send + Sync`. Use `Arc` and atomic types for shared state between hooks and the caller: ```rust use bashkit::{Bash, hooks::{HookAction, ExecOutput}}; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; # fn main() { let exec_count = Arc::new(AtomicU64::new(0)); let counter = exec_count.clone(); let bash = Bash::builder() .after_exec(Box::new(move |output: ExecOutput| { counter.fetch_add(1, Ordering::Relaxed); HookAction::Continue(output) })) .build(); # } ``` [`BashBuilder`]: crate::BashBuilder [`HookAction`]: crate::hooks::HookAction [`ExecInput`]: crate::hooks::ExecInput [`ExecOutput`]: crate::hooks::ExecOutput [`ToolEvent`]: crate::hooks::ToolEvent [`ToolResult`]: crate::hooks::ToolResult [`ExitEvent`]: crate::hooks::ExitEvent [`ErrorEvent`]: crate::hooks::ErrorEvent [`HttpRequestEvent`]: crate::hooks::HttpRequestEvent [`HttpResponseEvent`]: crate::hooks::HttpResponseEvent [`NetworkAllowlist`]: crate::NetworkAllowlist --- # Live mounts Source: https://bashkit.sh/docs/live-mounts.md Bashkit supports attaching and detaching filesystems on a **running** interpreter without rebuilding it. Shell state, environment variables, working directory, history, aliases, is fully preserved across mount operations. ## Motivation Before live mounts, the only way to add a filesystem after `build()` was to accumulate mount configs and call `reset()`, which rebuilds the entire interpreter and loses all in-flight state. Live mounts solve this by exposing the internal [`MountableFs`] layer that wraps every `Bash` instance. Common use cases: - **Agent workflows**: attach a host directory mid-session when a tool needs it - **Plugin systems**: mount/unmount plugin filesystems without restarting - **Hot-swap deployments**: replace a mounted app filesystem with a new version - **Testing**: inject mock data at specific paths during a test ## Quick Start ```rust use bashkit::{Bash, FileSystem, InMemoryFs}; use std::path::Path; use std::sync::Arc; # #[tokio::main] # async fn main() -> bashkit::Result<()> { let mut bash = Bash::new(); // Create and populate a filesystem let data_fs = Arc::new(InMemoryFs::new()); data_fs.write_file(Path::new("/users.json"), br#"["alice"]"#).await?; // Mount it live — no rebuild needed bash.mount("/mnt/data", data_fs)?; let result = bash.exec("cat /mnt/data/users.json").await?; assert!(result.stdout.contains("alice")); // Unmount when done bash.unmount("/mnt/data")?; # Ok(()) # } ``` ## API ### `Bash::mount(vfs_path, fs)` Mounts `fs` at `vfs_path`. The mount takes effect immediately, subsequent `exec()` calls see files from the mounted filesystem. If a mount already exists at `vfs_path`, it is replaced (hot-swap). ```rust # use bashkit::{Bash, FileSystem, InMemoryFs}; # use std::sync::Arc; # use std::path::Path; # #[tokio::main] # async fn main() -> bashkit::Result<()> { let bash = Bash::new(); let fs = Arc::new(InMemoryFs::new()); bash.mount("/mnt/data", fs)?; # Ok(()) # } ``` **Errors:** Returns `Err` if `vfs_path` is not absolute (after normalization). ### Read-only mounts (Python and Node bindings) The Python and Node bindings expose a per-mount `read_only` / `readOnly` flag on `mount()` (both `Bash` and the agent tool surface). A read-only mount wraps the mounted filesystem in [`ReadOnlyFs`]: reads keep working while mutations — shell redirections, `cp`, `mv`, `mkdir`, `rm` — and `chmod` fail at the host layer, so sandboxed code cannot rewrite a mounted corpus. `/tmp` and the rest of the VFS stay writable. The recorded handle is the wrapped one, so `reset()` replays the protection. ```python corpus = FileSystem() corpus.write_file("/report.txt", b"revenue 40,200,000\n") bash.mount("/corpus", corpus, read_only=True) ``` This is host-enforced protection: POSIX mode bits remain metadata-only and are *not* enforced — `chmod 444` alone does not stop a write. Use the `read_only` flag for projections nothing inside the sandbox may rewrite. ### `Bash::unmount(vfs_path)` Removes the mount at `vfs_path`. Paths that previously resolved to the mounted filesystem fall back to the root filesystem or the next shorter mount prefix. ```rust # use bashkit::{Bash, FileSystem, InMemoryFs}; # use std::sync::Arc; # #[tokio::main] # async fn main() -> bashkit::Result<()> { # let bash = Bash::new(); # let fs = Arc::new(InMemoryFs::new()); # bash.mount("/mnt/data", fs)?; bash.unmount("/mnt/data")?; # Ok(()) # } ``` **Errors:** Returns `Err` if nothing is mounted at `vfs_path`. ## How It Works Every `Bash` instance wraps its filesystem stack in a [`MountableFs`] as the outermost layer. This layer uses longest-prefix matching to route path operations to the correct mounted filesystem: ```text ┌──────────────────────────────┐ │ MountableFs (live mounts) │ ← Bash::mount() / unmount() ├──────────────────────────────┤ │ ReadOnlyFs (optional) │ ← BashBuilder::readonly_filesystem() ├──────────────────────────────┤ │ OverlayFs (text mounts) │ ← BashBuilder::mount_text() ├──────────────────────────────┤ │ MountableFs (real mounts) │ ← BashBuilder::mount_real_*_at() ├──────────────────────────────┤ │ Base filesystem │ ← InMemoryFs or custom └──────────────────────────────┘ ``` Because the interpreter holds an `Arc` pointing to the outermost `MountableFs`, any mount/unmount operation is visible to the interpreter immediately, no rebuild or state transfer required. When `BashBuilder::readonly_filesystem(true)` is used, the configured mounts and text files are installed first, then the final filesystem is wrapped so all later mutations fail. This is stricter than read-only real mounts: writes to `/tmp`, shell redirections, `cp`, `mv`, `mkdir`, `rm`, and `chmod` are also denied. ## Builder Mounts vs Live Mounts | | Builder mounts | Live mounts | |---|---|---| | **When** | Before `build()` | After `build()` | | **Method** | `BashBuilder::mount_text()`, `mount_real_*()` | `Bash::mount()` | | **State** | N/A (no interpreter yet) | Fully preserved | | **Use case** | Initial configuration | Dynamic attachment | Both approaches can be combined: configure initial mounts with the builder, then add/remove mounts at runtime. ## Live Environment [`Bash::set_env`] is the environment counterpart to a live mount: it applies an exported variable to a running interpreter, preserving shell state the same way. Together they let a host apply a *bundle* of setup, mount, env, and builtins, to an existing instance, rather than only through the builder: ```rust # use bashkit::{Bash, FileSystem, InMemoryFs}; # use std::path::Path; # use std::sync::Arc; # #[tokio::main] # async fn main() -> bashkit::Result<()> { let mut bash = Bash::new(); let skill_fs = Arc::new(InMemoryFs::new()); skill_fs.write_file(Path::new("/SKILL.md"), b"# my-skill\n").await?; bash.mount("/skills/my-skill", skill_fs)?; bash.set_env("SKILL_PATH", "/skills/my-skill"); let result = bash.exec(r#"cat "$SKILL_PATH/SKILL.md""#).await?; assert_eq!(result.stdout, "# my-skill\n"); # Ok(()) # } ``` The variable is exported, so scripts read it as `$NAME` and child contexts see it in `env`. A later script assignment wins; call `set_env` again to reassert the host value. In the JavaScript and Python bindings the equivalents are `setEnv` and `set_env`, and both bindings additionally *replay* host `setEnv`/`mount` calls across `reset()`, a rebuilt instance keeps the setup the host applied, while env a script exported is still discarded. The Rust core has no `reset()`; rebuilding is the embedder's own `BashBuilder` call. Files and symlinks can be copied or moved across live mount boundaries. A move snapshots the previous destination and restores it if copying or source removal fails. Directories and FIFOs remain unsupported across mount boundaries. ## Examples ### Multiple Mounts ```rust use bashkit::{Bash, FileSystem, InMemoryFs}; use std::path::Path; use std::sync::Arc; # #[tokio::main] # async fn main() -> bashkit::Result<()> { let mut bash = Bash::new(); let logs = Arc::new(InMemoryFs::new()); logs.write_file(Path::new("/app.log"), b"started\n").await?; let config = Arc::new(InMemoryFs::new()); config.write_file(Path::new("/app.toml"), b"port = 8080\n").await?; bash.mount("/var/log", logs)?; bash.mount("/etc/app", config)?; let result = bash.exec("cat /var/log/app.log").await?; assert_eq!(result.stdout, "started\n"); let result = bash.exec("cat /etc/app/app.toml").await?; assert_eq!(result.stdout, "port = 8080\n"); # Ok(()) # } ``` ### Hot-Swap Re-mounting at the same path replaces the filesystem atomically: ```rust use bashkit::{Bash, FileSystem, InMemoryFs}; use std::path::Path; use std::sync::Arc; # #[tokio::main] # async fn main() -> bashkit::Result<()> { let mut bash = Bash::new(); let v1 = Arc::new(InMemoryFs::new()); v1.write_file(Path::new("/version"), b"1.0").await?; bash.mount("/app", v1)?; let result = bash.exec("cat /app/version").await?; assert_eq!(result.stdout, "1.0"); // Hot-swap to v2 let v2 = Arc::new(InMemoryFs::new()); v2.write_file(Path::new("/version"), b"2.0").await?; bash.mount("/app", v2)?; let result = bash.exec("cat /app/version").await?; assert_eq!(result.stdout, "2.0"); # Ok(()) # } ``` ## See Also - [`MountableFs`], the underlying mount infrastructure - [`NamespaceFs`], static bounded namespaces with source-root rebasing and per-mount access - [`BashBuilder::mount_text`], pre-build text file mounts - [`BashBuilder::readonly_filesystem`], deny all VFS mutations after setup - [`BashBuilder::fs`], custom filesystem injection - [`Bash::fs`], direct filesystem access - [`Bash::set_env`], live environment on a running instance - [VFS specification](https://github.com/everruns/bashkit/blob/main/knowledge/foundations/vfs.md) --- # Snapshotting Source: https://bashkit.sh/docs/snapshotting.md Bashkit can serialize an interpreter into opaque bytes and restore it later. Use snapshots for checkpoint/resume flows, warm sandbox caching, or rolling back to a known-good virtual workspace. ## What a snapshot captures - Shell state: variables, exported env, arrays, aliases, and current working directory - Virtual filesystem contents by default - Session counters used by interpreter limits Pass snapshot options to skip VFS capture when you only want shell state. `restore_snapshot()` preserves the current instance configuration such as limits, builtins, and filesystem backend, then replaces shell state and VFS contents with the snapshot. `from_snapshot()` creates a fresh instance from bytes. In the Rust core, `Bash::from_snapshot()` returns a default-configured interpreter. If you need custom limits, builtins, or filesystem wiring, build that instance first and call `restore_snapshot()` on it. ## Rust ```rust use bashkit::{Bash, ExecutionLimits, SnapshotOptions}; # #[tokio::main] # async fn main() -> bashkit::Result<()> { let mut bash = Bash::new(); bash.exec("export BUILD_ID=42; mkdir -p /workspace && cd /workspace && echo ready > state.txt") .await?; let snapshot = bash.snapshot()?; let shell_only = bash.snapshot_with_options(SnapshotOptions { exclude_filesystem: true, exclude_functions: false, })?; let mut restored = Bash::from_snapshot(&snapshot)?; assert_eq!(restored.exec("echo $BUILD_ID").await?.stdout.trim(), "42"); assert_eq!( restored.exec("cat /workspace/state.txt").await?.stdout.trim(), "ready" ); // Reuse an explicitly configured instance and preserve its limits. let limits = ExecutionLimits::new().max_commands(100); let mut configured = Bash::builder().limits(limits).build(); configured.restore_snapshot(&snapshot)?; configured.restore_snapshot(&shell_only)?; # Ok(()) # } ``` ## Python Python exposes snapshotting on both `Bash` and `BashTool`: ```python from bashkit import Bash bash = Bash(username="agent", max_commands=100) bash.execute_sync( "export BUILD_ID=42; 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("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) ``` ## Node.js / TypeScript Node exposes snapshotting on `Bash` and `BashTool`: ```typescript import { Bash } from "@everruns/bashkit"; const bash = new Bash({ username: "agent", maxCommands: 100 }); bash.executeSync( "export BUILD_ID=42; mkdir -p /workspace && cd /workspace && echo ready > state.txt", ); const snapshot = bash.snapshot(); const shellOnly = bash.snapshot({ excludeFilesystem: true }); const promptOnly = bash.snapshot({ excludeFilesystem: true, excludeFunctions: true, }); const restored = Bash.fromSnapshot(snapshot, { username: "agent", maxCommands: 100, }); if (restored.executeSync("echo $BUILD_ID").stdout.trim() !== "42") { throw new Error("snapshot restore failed"); } restored.reset(); restored.restoreSnapshot(snapshot); restored.restoreSnapshot(shellOnly); ``` ## Session history: commits, forks, and rewinds A packed snapshot is one self-contained blob, which is the right shape for checkpoint/resume but the wrong shape for keeping a snapshot per conversation turn: each blob re-encodes the whole workspace, so unchanged files are stored again and again, and a branch cannot share anything with its parent. For that, use commits. A commit captures the same state as a content-addressed object graph, file chunks, a tree, shell state, and hands you the objects to persist plus a `CommitId` to remember. Unchanged content is a 32-byte hash reference, not a copy. ```rust use bashkit::{Bash, CheckoutPolicy, CommitOptions, ObjectId, SnapshotGraph}; use std::collections::HashMap; # #[tokio::main] # async fn main() -> bashkit::Result<()> { // Any key-value store works. Bashkit never touches it directly. let mut store: HashMap> = HashMap::new(); let mut bash = Bash::new(); bash.exec("echo one > /log.txt").await?; let first = bash.commit(CommitOptions::new())?; let first_id = first.id(); store.extend(first.into_objects()); bash.exec("echo two >> /log.txt").await?; // `have` makes the commit incremental: objects the store already holds are // not emitted again. let second = bash.commit( CommitOptions::new() .parent(first_id) .have(store.keys()) .meta("message_id", "msg-2"), )?; let second_id = second.id(); store.extend(second.into_objects()); // Rewind, or fork: check out any commit, tip or not. No copy, no replay. let mut branch = Bash::new(); branch.checkout(first_id, &store, CheckoutPolicy::default())?; assert_eq!(branch.exec("cat /log.txt").await?.stdout, "one\n"); // What changed during that turn? let diff = SnapshotGraph::diff(first_id, second_id, &store)?; assert_eq!(diff.files_modified, vec!["/log.txt".to_string()]); // Ancestry walks the parent pointers your store holds. let history = SnapshotGraph::ancestry(second_id, &store, 100)?; assert_eq!(history, vec![second_id, first_id]); # Ok(()) # } ``` A fork is simply a commit whose parent is not the branch tip, there is no separate fork operation. Truncating a session is pointing at an older `CommitId`. Objects no commit reaches are yours to collect; `SnapshotGraph::reachable` lists what a commit needs. ### Python ```python import sqlite3 from bashkit import Bash, SnapshotGraph db = sqlite3.connect("sessions.db") db.execute("CREATE TABLE IF NOT EXISTS objects (id TEXT PRIMARY KEY, blob BLOB)") def store_objects(objects: dict[str, bytes]) -> None: # Objects are immutable and keyed by their own hash, so INSERT OR IGNORE is # the whole write path — concurrent sessions converge instead of conflicting. db.executemany("INSERT OR IGNORE INTO objects VALUES (?, ?)", objects.items()) db.commit() def known_ids() -> list[str]: return [row[0] for row in db.execute("SELECT id FROM objects")] def load_all() -> dict[str, bytes]: return {row[0]: row[1] for row in db.execute("SELECT id, blob FROM objects")} bash = Bash() bash.execute_sync("echo one > /log.txt") first = bash.commit(meta={"message_id": "msg-1"}) store_objects(first.objects) bash.execute_sync("echo two >> /log.txt") second = bash.commit(parents=[first.id], have=known_ids(), meta={"message_id": "msg-2"}) store_objects(second.objects) # Rewind, or fork: check out any commit, tip or not. branch = Bash() branch.checkout(first.id, load_all()) assert branch.execute_sync("cat /log.txt").stdout == "one\n" diff = SnapshotGraph.diff(first.id, second.id, load_all()) assert diff.files_modified == ["/log.txt"] ``` Object ids are hex strings and stores are plain `dict[str, bytes]`, so both drop straight into a database column with no marshalling. A full runnable example, including lazy fetching and garbage collection, is in `crates/bashkit-python/examples/session_history.py`. ### Node.js / TypeScript ```typescript import { Bash, snapshotDiff, snapshotAncestry } from "@everruns/bashkit"; const store: Record = {}; const bash = new Bash(); bash.executeSync("echo one > /log.txt"); const first = bash.commit({ meta: { messageId: "msg-1" } }); Object.assign(store, first.objects); bash.executeSync("echo two >> /log.txt"); const second = bash.commit({ parents: [first.id], have: Object.keys(store), meta: { messageId: "msg-2" }, }); Object.assign(store, second.objects); const branch = new Bash(); branch.checkout(first.id, store); const diff = snapshotDiff(first.id, second.id, store); const history = snapshotAncestry(second.id, store); ``` `commit()` returns `packed`, self-contained bytes equivalent to `snapshot()`, or `null` when `have` made the commit incremental, since packing one would produce bytes that cannot be restored. ### Fetching objects on demand `checkout` takes the objects it needs rather than calling back into your storage, which keeps database access on your side of the boundary. When you do not want to load a whole session's objects up front, ask what is missing, fetch that, and repeat until the plan comes back empty: ```rust,ignore loop { let need = SnapshotGraph::plan_checkout(commit_id, &local)?; if need.is_empty() { break; } for id in need { local.insert(id, my_database.load(id).await?); } } bash.checkout(commit_id, &local, CheckoutPolicy::default())?; ``` Chunk IDs live inside file manifests, so a cold start takes a few rounds; the graph is four levels deep, so it always converges quickly. ## Capability checks on restore Every commit records the environment that produced it: builtin names, the compile-time features that change interpreter semantics, and the filesystem backend. On restore that fingerprint is compared against the live instance. | Policy | Rule | |---|---| | `Superset` (default) | The live instance must have everything the snapshot's had. Extra builtins are fine. | | `Strict` | Environments must match exactly. Expect this to reject snapshots from any other bashkit version. | | `Force` | Restore regardless. | The default is `Superset` because the dangerous direction is asymmetric: restoring into an environment missing a tool the session used can produce a broken session, while extra tools cannot. It also means snapshots keep restoring after a bashkit upgrade adds builtins, under `Strict` they would not. The fingerprint proves the environments match; it cannot prove a restored session will behave. Where state provably requires a feature, the state itself is checked: a workspace holding SQLite databases is refused on a build without the `sqlite` feature under every policy. `SnapshotGraph::capabilities(id, &store)` reads the fingerprint without restoring, and `CapabilityFingerprint::capture(&bash)` fingerprints a live instance, so you can test compatibility before committing to a restore. ## Format versions and upgrades Snapshot bytes carry three version numbers: the container framing, the metadata schema, and a `min_reader` floor. A build reads anything whose `min_reader` it satisfies, however much newer the other two are, and unknown metadata fields are ignored rather than rejected. A snapshot that genuinely needs a newer bashkit fails with `Error::SnapshotTooNew` naming both versions, so you can prompt an upgrade instead of discarding stored state. Snapshots written by older bashkit versions, including the original v1 JSON format, keep restoring. A checked-in corpus of snapshots from every released format version is replayed on every CI run to keep that true. ## Security note The default snapshot format includes integrity checks for accidental corruption, but it does not authenticate untrusted bytes. If snapshots cross trust boundaries such as shared storage or network transfer, use Rust's keyed APIs (`snapshot_to_bytes_keyed`, `from_snapshot_keyed`, `restore_snapshot_keyed`) or treat the snapshot bytes as trusted-only input. Within a commit graph, every object is named by the hash of its content and re-verified when loaded, so authenticating the root commit authenticates everything beneath it. Objects loaded from a store you do not control are still checked, a substituted or truncated object fails before any state is applied. ## See also - [Security](./security.md) - [Embedded Python guide](../crates/bashkit/docs/python.md) - [Embedded TypeScript guide](../crates/bashkit/docs/typescript.md) --- # Logging Source: https://bashkit.sh/docs/logging.md Bashkit provides optional structured logging via the `tracing` crate when the `logging` feature is enabled. This guide covers configuration, log levels, security considerations, and integration with your application. **See also:** - [Threat Model](./threat-model.md) - Security threats including TM-LOG-* - [API Documentation](https://docs.rs/bashkit) - Full API reference ## Enabling Logging Add the `logging` feature to your `Cargo.toml`: ```toml [dependencies] bashkit = { version = "0.20.1", features = ["logging"] } tracing-subscriber = "0.3" # or your preferred subscriber ``` ## Log Levels Bashkit emits logs at five levels: | Level | Target | When Used | |-------|--------|-----------| | **ERROR** | `bashkit::session` | Unrecoverable failures, script errors, security violations | | **WARN** | `bashkit::parser` | Parse errors, recoverable issues | | **INFO** | `bashkit::session` | Session start/end, execution completion | | **DEBUG** | `bashkit::parser`, `bashkit::interpreter` | Parsing, command execution flow | | **TRACE** | `bashkit::interpreter` | Detailed internal state (not currently emitted) | ### Example Log Output ```text INFO bashkit::session: Starting script execution script="[script: 3 lines, 45 bytes]" DEBUG bashkit::parser: Parsing script input_len=45 max_ast_depth=100 max_operations=100000 DEBUG bashkit::parser: Parse completed successfully DEBUG bashkit::interpreter: Starting interpretation INFO bashkit::session: Script execution completed exit_code=0 stdout_len=12 stderr_len=0 ``` ## Configuration ### Basic Setup ```rust,ignore use bashkit::{Bash, LogConfig}; use tracing_subscriber::{fmt, prelude::*, EnvFilter}; fn main() { // Initialize tracing subscriber tracing_subscriber::registry() .with(fmt::layer()) .with(EnvFilter::from_default_env()) .init(); // Bashkit will now emit logs let mut bash = Bash::builder() .log_config(LogConfig::new()) .build(); } ``` ### Environment Variables Control log levels via `RUST_LOG`: ```bash # All Bashkit logs at debug level RUST_LOG=bashkit=debug cargo run # Only session lifecycle (info level) RUST_LOG=bashkit::session=info cargo run # Parser details only RUST_LOG=bashkit::parser=debug cargo run ``` ### Custom Configuration ```rust use bashkit::{Bash, LogConfig}; # fn main() { let bash = Bash::builder() .log_config(LogConfig::new() // Add custom sensitive variable patterns .redact_env("MY_INTERNAL_SECRET") .redact_env("COMPANY_API_KEY") // Limit logged value lengths .max_value_length(100)) .build(); # } ``` ## Security (TM-LOG-*) Bashkit's logging is designed with security as a priority. Sensitive data is redacted by default to prevent accidental exposure in logs. ### What Gets Redacted 1. **Environment Variables** (TM-LOG-001) - Variables matching patterns: PASSWORD, SECRET, TOKEN, KEY, AUTH, etc. - Custom patterns added via `redact_env()` 2. **Script Content** (TM-LOG-002) - By default, only script metadata (lines, bytes) is logged - Full content is never logged unless explicitly enabled 3. **URL Credentials** (TM-LOG-003) - `https://user:pass@host.com` → `https://[REDACTED]@host.com` 4. **API Keys and Tokens** (TM-LOG-004) - JWTs (three-part base64 strings) - API keys with common prefixes (`sk-`, `ghp_`, `AKIA`, etc.) - High-entropy strings that look like secrets ### Log Injection Prevention (TM-LOG-005, TM-LOG-006) Script content is sanitized to prevent log injection attacks: ```text // Attacker tries: "echo hello\n[ERROR] Security breach!" // Logged as: "echo hello\\n[ERROR] Security breach!" ``` Control characters are filtered, and newlines are escaped. ### Unsafe Options For debugging in **non-production** environments only. These methods require the `BASHKIT_UNSAFE_LOGGING=1` environment variable to take effect; without it they are no-ops that emit a warning: ```rust,ignore // First: export BASHKIT_UNSAFE_LOGGING=1 in your shell // WARNING: May expose sensitive data let config = LogConfig::new() .unsafe_disable_redaction() // Disable ALL redaction .unsafe_log_scripts(); // Log full script content ``` **Never use these options in production.** ## Integration Examples ### With tokio-console ```rust,ignore use bashkit::Bash; use console_subscriber; fn main() { console_subscriber::init(); let mut bash = Bash::new(); // Logs will appear in tokio-console } ``` ### With JSON Logging ```rust,ignore use bashkit::Bash; use tracing_subscriber::{fmt, prelude::*}; fn main() { tracing_subscriber::registry() .with(fmt::layer().json()) .init(); let mut bash = Bash::new(); // Logs will be JSON formatted } ``` ### Filtering by Component ```rust,ignore use bashkit::Bash; use tracing_subscriber::{fmt, prelude::*, EnvFilter}; fn main() { let filter = EnvFilter::new("") .add_directive("bashkit::session=info".parse().unwrap()) .add_directive("bashkit::parser=warn".parse().unwrap()); tracing_subscriber::registry() .with(fmt::layer()) .with(filter) .init(); let mut bash = Bash::new(); } ``` ## Log Targets | Target | Description | |--------|-------------| | `bashkit::session` | Script lifecycle (start, complete, error) | | `bashkit::parser` | Parsing operations and errors | | `bashkit::interpreter` | Command execution flow | | `bashkit::config` | Configuration and builder operations | ## Performance Considerations - Logging is compile-time optional via feature flag - When disabled (`--no-default-features`), zero overhead - When enabled but filtered (e.g., `RUST_LOG=error`), minimal overhead - Redaction operations are optimized to avoid unnecessary allocations ## Troubleshooting ### No Logs Appearing 1. Ensure the `logging` feature is enabled 2. Initialize a tracing subscriber before creating `Bash` 3. Set `RUST_LOG` environment variable appropriately ### Sensitive Data in Logs 1. Check if `unsafe_disable_redaction()` was called 2. Verify custom env var patterns with `redact_env()` 3. Ensure script content logging is disabled (default) ### Too Much Log Output 1. Use `EnvFilter` to limit to specific targets 2. Set higher log level (e.g., `RUST_LOG=bashkit=warn`) 3. Reduce `max_value_length` in `LogConfig`