A deterministic, sandbox-only, bash-like runtime for AI agents, implemented in Go.
Shell parsing and execution are owned in-tree under internal/shell, with a project-owned virtual filesystem, registry-backed command execution, policy enforcement, and structured tracing around that shell core. Commands never fall through to host binaries, and network access is off by default. Originally inspired by Vercel's just-bash.
Warning
This is alpha software. It is likely that additional security hardening is needed. Use with care.
- Features
- Public Packages
- Installation
- Quick Start
- Usage
- Configuration
- Security Model
- Supported Commands
- Shell Features
- Default Sandbox Layout
- Examples
- Development
- License
- Virtual in-memory filesystem — no host access by default
- Registry-backed command execution — unknown commands never run host binaries
- 90+ built-in commands with GNU coreutils compatibility coverage (compatibility report)
- Optional allowlisted network access via
curl - Persistent sessions with shared filesystem state across executions
- Shared JSON-RPC server mode for session-oriented hosts and wrapper binaries
- Host directory mounting with read-only overlay for real project workspaces
- Execution budgets — command count, loop iterations, glob expansion, stdout/stderr limits
- Opt-in structured trace events and lifecycle logs for debugging and agent orchestration
- WebAssembly support — runs in the browser (demo)
github.com/ewhauser/gbash: the core Go runtime and embedding APIgithub.com/ewhauser/gbash/host: the public host adapter boundary for platform and process behaviorgithub.com/ewhauser/gbash/server: shared JSON-RPC server mode for hosting persistent gbash sessionsgithub.com/ewhauser/gbash/contrib/...: optional Go command and tool modules@ewhauser/gbash-wasm/browser: the explicit browser entrypoint for thejs/wasmpackage. It is versioned in-repo today; npm publishing remains disabled in the release workflow for now.@ewhauser/gbash-wasm/node: the explicit Node entrypoint for the samejs/wasmpackage.
Library:
go get github.com/ewhauser/gbashCLI:
go install github.com/ewhauser/gbash/cmd/gbash@latestExtras CLI:
go install github.com/ewhauser/gbash/contrib/extras/cmd/gbash-extras@latestPrebuilt gbash and gbash-extras archives are also available on the GitHub Releases page.
Released Go modules are also requested from the public Go proxy during the release workflow so their API docs stay current on pkg.go.dev.
The coordinated release workflow also exports the website and deploys it to GitHub Pages, preserving raw compatibility assets under /compat/latest/ for the published compatibility report.
Try it with go run — no install required:
go run github.com/ewhauser/gbash/cmd/gbash@latest -c 'echo hello; pwd; ls -la'You should see hello, the default working directory /home/agent, and the initial listing for the empty sandbox home directory.
Everything runs inside a virtual filesystem — nothing touches your host.
Use gbash.New to configure a runtime and Runtime.Run for one-shot execution.
package main
import (
"context"
"fmt"
"github.com/ewhauser/gbash"
)
func main() {
gb, err := gbash.New()
if err != nil {
panic(err)
}
result, err := gb.Run(context.Background(), &gbash.ExecutionRequest{
Script: "echo hello\npwd\n",
})
if err != nil {
panic(err)
}
fmt.Printf("exit=%d\n", result.ExitCode)
fmt.Print(result.Stdout)
}exit=0
hello
/home/agent
Use Session.Exec when you want multiple shell executions to share one sandbox filesystem.
package main
import (
"context"
"fmt"
"github.com/ewhauser/gbash"
)
func main() {
ctx := context.Background()
gb, err := gbash.New()
if err != nil {
panic(err)
}
session, err := gb.NewSession(ctx)
if err != nil {
panic(err)
}
if _, err := session.Exec(ctx, &gbash.ExecutionRequest{
Script: "echo hello > /shared.txt\n",
}); err != nil {
panic(err)
}
result, err := session.Exec(ctx, &gbash.ExecutionRequest{
Script: "cat /shared.txt\npwd\n",
})
if err != nil {
panic(err)
}
fmt.Print(result.Stdout)
}hello
/home/agent
Pipe a script to the CLI to execute it inside the sandbox:
printf 'echo hi\npwd\n' | gbashhi
/home/agent
When stdin is a terminal, the CLI starts an interactive shell automatically:
gbashYou can also force interactive mode explicitly:
printf 'pwd\ncd /tmp\npwd\nexit\n' | gbash -iThe interactive shell reuses one sandbox session and carries forward filesystem and environment state. It exposes a session-local history command, but it does not provide readline-style line editing or job control.
For host-backed CLI runs, you can switch the filesystem mode explicitly:
gbash --root /path/to/project --cwd /home/agent/project -c 'pwd; ls'--root mounts a host directory read-only at /home/agent/project under an in-memory writable overlay. --cwd sets the initial sandbox working directory.
For programmatic wrappers and harnesses, non-interactive runs can emit one structured JSON object instead of streaming stdout and stderr directly:
gbash -c 'echo hello' --jsonThe JSON payload includes stdout, stderr, exitCode, truncation flags, timing metadata, and trace metadata when the wrapper enables tracing on the underlying runtime.
For parser and tooling workflows, you can dump the parsed shell AST without executing the script:
gbash --dump-ast -c 'echo hello'--dump-ast writes pretty-printed typed JSON for the parsed shell/syntax tree. Add --detect to choose the parser variant from a leading shebang first, then a positional file extension for file-backed inputs, and otherwise fall back to bash.
For long-lived agent or editor integrations, the same shared CLI frontend can serve a JSON-RPC protocol instead of executing one script:
gbash --server --socket /tmp/gbash.sock --session-ttl 30mgbash --server --listen 127.0.0.1:8080 --session-ttl 30mThe server speaks JSON-RPC 2.0 over either a Unix socket or an explicit loopback TCP listener. session_id maps 1:1 to a persistent sandbox session, and session.exec runs one non-interactive shell execution inside that session and returns the full result in one response. Filesystem shape is still chosen at server startup through the normal CLI/runtime options such as --root, --readwrite-root, and --cwd; it is not configured over the wire. The shared CLI requires exactly one transport flag, --socket PATH or --listen HOST:PORT, and --listen is restricted to loopback hosts because the protocol has no built-in authentication.
Install gbash-extras when you want the same CLI surface with the stable official contrib commands (awk, html-to-markdown, jq, sqlite3, and yq) pre-registered:
gbash-extras -c 'jq -r .name data.json'gbash-extras --server --socket /tmp/gbash-extras.sock and gbash-extras --server --listen 127.0.0.1:8081 expose the same JSON-RPC protocol with the stable extras registry already installed.
The shared frontend is also exposed as the public github.com/ewhauser/gbash/cli package. Call cli.Run with a cli.Config to reuse the stock flag parsing, interactive mode, server mode, and runtime setup from your own wrapper binary. For direct embedding without going through the CLI package, use github.com/ewhauser/gbash/server.
Most callers should use one of these entry points:
gbash.New()— default mutable in-memory sandboxgbash.WithFileSystem(gbash.SeededInMemoryFileSystem(...))— in-memory sandbox preloaded with eager or lazy filesgbash.WithWorkspace(root)— real host directory mounted read-only under an in-memory overlaygbash.WithFileSystem(gbash.MountableFileSystem(...))— multi-mount namespace over a base filesystem plus sibling mountsgbash.WithFileSystem(gbash.ReadWriteDirectoryFileSystem(...))— just-bash-style mutable host-backed rootgbash.WithFileSystem(gbash.CustomFileSystem(...))— custom backends
Preload an in-memory sandbox with eager or lazy files:
gb, err := gbash.New(
gbash.WithFileSystem(gbash.SeededInMemoryFileSystem(gbfs.InitialFiles{
"/home/agent/config.json": {Content: []byte("{\"mode\":\"dev\"}\n")},
"/home/agent/big.txt": {
Lazy: func(ctx context.Context) ([]byte, error) {
return fetchLargeFixture(ctx)
},
},
})),
)Compose multiple sandbox mounts under one namespace:
gb, err := gbash.New(
gbash.WithFileSystem(gbash.MountableFileSystem(gbash.MountableFileSystemOptions{
Mounts: []gbfs.MountConfig{
{MountPoint: "/workspace", Factory: gbfs.Overlay(gbfs.Host(gbfs.HostOptions{Root: "/path/to/project", VirtualRoot: "/"}))},
{MountPoint: "/cache", Factory: gbfs.Memory()},
},
})),
)Mount a host directory as a read-only workspace overlay:
gb, err := gbash.New(
gbash.WithWorkspace("/path/to/project"),
)This mounts the host directory read-only at /home/agent/project, starts the session there, and keeps all writes in the in-memory upper layer.
For full control over the mount point or host-file read cap:
gb, err := gbash.New(
gbash.WithFileSystem(gbash.HostDirectoryFileSystem("/path/to/project", gbash.HostDirectoryOptions{
MountPoint: "/home/agent/project",
})),
)If you want writes to persist directly back to the host directory instead of landing in the in-memory overlay, use the read/write helper:
gb, err := gbash.New(
gbash.WithFileSystem(gbash.ReadWriteDirectoryFileSystem("/path/to/project", gbash.ReadWriteDirectoryOptions{})),
gbash.WithWorkingDir("/"),
)This mode maps the host directory to sandbox /, so it is best suited to compatibility harnesses and other opt-in developer workflows.
By default, gbash uses an internal virtual host adapter. That preserves the existing sandbox behavior: virtual pipes, virtual process metadata, and build-dependent platform defaults.
Embedders can opt into the underlying process and OS view with WithHost(host.NewSystem()):
import (
"github.com/ewhauser/gbash"
"github.com/ewhauser/gbash/host"
)
gb, err := gbash.New(
gbash.WithHost(host.NewSystem()),
)Network access is disabled by default. Enable it to register curl in the sandbox.
For simple URL allowlisting, use WithHTTPAccess:
package main
import (
"context"
"fmt"
"github.com/ewhauser/gbash"
)
func main() {
gb, err := gbash.New(
gbash.WithHTTPAccess("https://api.example.com/v1/"),
)
if err != nil {
panic(err)
}
result, err := gb.Run(context.Background(), &gbash.ExecutionRequest{
Script: "curl -o /tmp/status.json https://api.example.com/v1/status\ncat /tmp/status.json\n",
})
if err != nil {
panic(err)
}
fmt.Print(result.Stdout)
}For fine-grained control over methods, response limits, and private-range blocking, use WithNetwork:
gb, err := gbash.New(
gbash.WithNetwork(&gbash.NetworkConfig{
AllowedURLPrefixes: []string{"https://api.example.com/v1/"},
AllowedMethods: []gbash.Method{gbash.MethodGet, gbash.MethodHead},
MaxResponseBytes: 10 << 20,
DenyPrivateRanges: true,
}),
)Allowed URL prefixes are origin- and path-boundary aware. For example, https://api.example.com/v1 matches /v1 and /v1/..., but not /v10.
For full transport control in tests or embedding, inject your own Config.NetworkClient.
See examples/oauth-network-extension for a demo that injects OAuth headers from a host-side vault so the sandbox never sees the bearer token.
Tracing and logging are disabled by default.
Use WithTracing(TraceConfig{Mode: gbash.TraceRedacted}) to populate ExecutionResult.Events for non-interactive runs and to receive structured OnEvent callbacks for both non-interactive and interactive executions. TraceRedacted is the recommended mode for agent workloads. TraceRaw preserves full argv and path metadata and should only be used when you control the sink and retention policy.
Use WithLogger to receive top-level lifecycle logs: exec.start, stdout, stderr, exec.finish, and exec.error. Logger callbacks receive the same captured stdout and stderr strings returned in ExecutionResult.
- The shell only sees the filesystem and runtime configuration you provide.
- Command execution is registry-backed. Unknown commands never execute host binaries.
- Network access is off by default. When enabled, requests are constrained by allowlists and runtime limits.
- The default static policy applies execution budgets such as command-count, loop-iteration, glob-expansion, substitution-depth, and stdout/stderr capture limits.
- Structured trace events are opt-in. Redacted tracing is the recommended default, and raw tracing is unsafe unless you tightly control where events go.
This is not a hardened sandbox. If you need stronger containment against denial-of-service or runtime bugs, use OS- or process-level isolation around it. For a detailed threat analysis, see THREAT_MODEL.md.
The default runtime exposes registry-backed commands plus shell builtins and shims. Use gbash.DefaultRegistry() to start from the stock registry-backed command set and register custom commands on top.
| Category | Commands |
|---|---|
| File and path | basename cat chmod chown cp dircolors dirname du file find ln link ls dir mkdir mktemp mv readlink realpath rm rmdir stat touch tree truncate unlink vdir |
| Search and text | base32 base64 basenc column comm csplit cut diff egrep fgrep grep head join nl numfmt od paste printf rev rg sed seq sort split strings tac tail tee tr uniq wc xan |
| Archive | gzip gunzip tar zcat |
| Builtins | . : [ alias break builtin cd command continue declare dirs echo eval exec exit export false getopts hash help history let local mapfile popd printf pushd pwd read readarray readonly return set shift shopt source test trap true type typeset unalias unset wait |
| Environment and execution | arch b2sum bash cksum clear date echo env expr factor false help history id md5sum printenv pwd sh sha1sum sha224sum sha256sum sha384sum sha512sum sleep sum test timeout true tsort tty uname uptime which who whoami xargs yes |
| Network (when configured) | curl |
| Extras* | awk html-to-markdown jq sqlite3 yq |
* Use gbash-extras for extras commands.
Some builtin names, such as echo, help, history, printf, pwd, test, true, and false, are routed to gbash's registry-backed implementations and therefore also appear in the command categories above.
Shell language support also includes indexed arrays and associative arrays.
Many commands are ported from uutils/coreutils and have full GNU flag parity. See the current compatibility report.
Optional contrib packages live in contrib/ as separate Go modules so the core library stays dependency-light. Command modules are not registered by default, and helper/tool modules do not change the default runtime surface.
| Module | Import Path | Backed by |
|---|---|---|
awk |
github.com/ewhauser/gbash/contrib/awk |
benhoyt/goawk |
bashtool |
github.com/ewhauser/gbash/contrib/bashtool |
gbash runtime execution plus the upstream bashkit Bash tool contract |
codingtools |
github.com/ewhauser/gbash/contrib/codingtools |
gbash/fs.FileSystem plus the upstream pi-mono coding-agent read, edit, and write tool semantics |
html-to-markdown |
github.com/ewhauser/gbash/contrib/htmltomarkdown |
JohannesKaufmann/html-to-markdown |
jq |
github.com/ewhauser/gbash/contrib/jq |
itchyny/gojq |
sqlite3 |
github.com/ewhauser/gbash/contrib/sqlite3 |
ncruces/go-sqlite3 |
yq |
github.com/ewhauser/gbash/contrib/yq |
mikefarah/yq |
Use github.com/ewhauser/gbash/contrib/extras to register all contrib commands at once:
import "github.com/ewhauser/gbash/contrib/extras"
gb, err := gbash.New(gbash.WithRegistry(extras.FullRegistry()))The same stable set is bundled in the gbash-extras CLI at github.com/ewhauser/gbash/contrib/extras/cmd/gbash-extras.
See the custom-zstd example for how to register custom commands.
Shell parsing and execution are provided by the project-owned shell core under internal/shell, with project-owned filesystem, command, policy, and observability layers around it.
The runtime supports a practical shell subset for agent workflows, including:
- pipelines and redirections
- variable expansion and command substitution
- conditionals and loops
- shell functions and common builtins handled by the in-tree shell core
- virtual
cdandpwdbehavior against the sandbox filesystem - nested
bashandshexecution inside the same sandbox session
It is intentionally not a full Bash reimplementation. It does not aim to provide full GNU Bash compatibility, readline-style UX, history navigation/editing, job control, or host TTY emulation.
Each fresh session starts with a Unix-like virtual layout:
- home and default working directory:
/home/agent - scratch directory:
/tmp - command directories:
/usr/binand/bin - default
PATH:/usr/bin:/bin
Those command paths are virtual stubs used for shell resolution. Command implementations still come from the Go registry, not the host filesystem.
| Example | Description |
|---|---|
adk-bash-chat |
Local CLI chatbot using adk-go with a persistent gbash bash tool session and a seeded ops analytics lab |
agentfs-backed-fs |
Uses the upstream AgentFS Go SDK as a custom persistent gbash filesystem backend |
custom-zstd |
Demonstrates custom command registration by adding a zstd compression/decompression command |
gbash-eval |
Standalone LLM evaluator example that ports bashkit's crates/bashkit-eval design and vendored JSONL datasets onto gbash |
openai-tool-call |
Uses the OpenAI Go SDK Responses API with gbash as a bash function tool |
sqlite-backed-fs |
Custom gbfs.FileSystem backed by a SQLite database for persistent sandbox filesystem state |
transactional-workspaces |
Narrated snapshot/rollback/branch demo showing how gbash sessions become reversible, inspectable shell workspaces |
The gbash-eval example is intentionally attribution-heavy because it is a direct Go port of upstream evaluator ideas and copied dataset fixtures from everruns/bashkit, adapted under Apache-2.0 for gbash.
Smoke test from the repo root:
export OPENAI_API_KEY=your-api-key
go run ./examples/gbash-eval run \
--dataset ./examples/gbash-eval/data/smoke-test.jsonl \
--provider openresponses \
--model gpt-5-codex \
--saveTo run only selected eval task IDs, add --task with repeated or comma-separated values.
make build, make test, and make lint cover the Go modules. make test now resolves the pinned GNU diff and ripgrep oracles through Nix via ./scripts/ensure-diffutils.sh and ./scripts/ensure-ripgrep.sh; set GBASH_CONFORMANCE_DIFF or GBASH_CONFORMANCE_RIPGREP if you want to override either path explicitly. make conformance-test resolves the pinned bash, dash, mksh, zsh, and curl oracles through Nix via ./scripts/ensure-bash.sh, ./scripts/ensure-dash.sh, ./scripts/ensure-mksh.sh, ./scripts/ensure-zsh.sh, and ./scripts/ensure-curl.sh; set GBASH_CONFORMANCE_BASH, GBASH_CONFORMANCE_DASH, GBASH_CONFORMANCE_MKSH, GBASH_CONFORMANCE_ZSH, or GBASH_CONFORMANCE_CURL to override those paths explicitly. make contrib-conformance-test runs the contrib parity suites, currently the package-local jq, awk, and yq corpora under contrib/jq, contrib/awk, and contrib/yq, using pinned bash, jq, awk, and yq oracles from ./scripts/ensure-bash.sh, ./scripts/ensure-jq.sh, ./scripts/ensure-awk.sh, and ./scripts/ensure-yq.sh; set GBASH_CONFORMANCE_BASH, GBASH_CONFORMANCE_JQ, GBASH_CONFORMANCE_AWK, or GBASH_CONFORMANCE_YQ to override those paths explicitly. See CONTRIBUTING.md for repository structure, module versioning, release process, benchmarks, and GNU coreutils compatibility testing.
For the documentation site, run make website-dev to start a local Next.js server seeded with the latest published compatibility assets from /compat/latest/.
This project is licensed under the Apache License 2.0. Copyright 2026 Eric Hauser.
just-bashsparked the idea forgbashand provided the starting point for the initial port.mvdan/shprovides the shell parser and interpreter foundation thatgbashforks and builds on in-tree.uutils/coreutilsprovides the baseline behavior and implementations behind many of the command ports.bashkitprovided the approach for bash conformance testing and agent evals