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.
curlgithub.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 the js/wasm package. 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 same js/wasm package.Library:
go get github.com/ewhauser/gbash
CLI:
go install github.com/ewhauser/gbash/cmd/gbash@latest
Extras CLI:
go install github.com/ewhauser/gbash/contrib/extras/cmd/gbash-extras@latest
Prebuilt 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' | gbash
hi
/home/agent
When stdin is a terminal, the CLI starts an interactive shell automatically:
gbash
You can also force interactive mode explicitly:
printf 'pwd\ncd /tmp\npwd\nexit\n' | gbash -i
The 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' --json
The 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 30m
gbash --server --listen 127.0.0.1:8080 --session-ttl 30m
The 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 backendsPreload 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.
$ claude mcp add gbash \
-- python -m otcore.mcp_server <graph>