A virtual bash environment with an in-memory filesystem, written in Go and designed for AI agents.
Broad support for standard Unix commands and bash syntax with optional network access, SQLite, and (planned) JavaScript and Python runtimes.
go-bash is inspired by
just-bash (TypeScript) —
similar bash constructs, builtin set, and VFS shape — but running
in-process under a Go runtime, with CGO_ENABLED=0 builds and no
os/exec reachable from script context.
Note: This is beta software. The public API is stable, but
byte-level output of some builtins may shift as we close gaps against
real bash. Pin a version in go.mod. See security
model.
go get github.com/mark3labs/go-bash@latestpackage main
import (
"context"
"fmt"
gobash "github.com/mark3labs/go-bash"
)
func main() {
ctx := context.Background()
b, _ := gobash.New(gobash.BashOptions{})
_, _ = b.Exec(ctx, `echo "Hello" > greeting.txt`, gobash.ExecOptions{})
res, _ := b.Exec(ctx, "cat greeting.txt", gobash.ExecOptions{})
fmt.Println(res.Stdout) // "Hello\n"
fmt.Println(res.ExitCode) // 0
}Each Exec() call gets its own isolated shell state — environment
variables, functions, and working directory reset between calls. The
filesystem is shared across calls, so files written in one
Exec() are visible in the next.
Extend go-bash with your own Go commands using command.Define:
import (
"context"
"fmt"
"io"
"strings"
gobash "github.com/mark3labs/go-bash"
"github.com/mark3labs/go-bash/command"
)
hello := command.Define("hello", func(ctx context.Context, args []string, c *command.Context) command.Result {
name := "world"
if len(args) > 1 {
name = args[1]
}
fmt.Fprintf(c.Stdout, "Hello, %s!\n", name)
return command.Result{ExitCode: 0}
})
upper := command.Define("upper", func(ctx context.Context, args []string, c *command.Context) command.Result {
data, _ := io.ReadAll(c.Stdin)
fmt.Fprint(c.Stdout, strings.ToUpper(string(data)))
return command.Result{ExitCode: 0}
})
b, _ := gobash.New(gobash.BashOptions{
CustomCommands: []command.Command{hello, upper},
})
b.Exec(ctx, "hello Alice", gobash.ExecOptions{}) // "Hello, Alice!\n"
b.Exec(ctx, "echo 'test' | upper", gobash.ExecOptions{}) // "TEST\n"Custom commands receive a command.Context with FS, Cwd, Env,
Stdin, Stdout, Stderr, Fetch (network Doer), and Exec (for
sub-shell invocation). They participate in pipes, redirections,
exit-code semantics, and set -e propagation like any builtin.
Custom commands override builtins with the same name — useful for
swapping in a stricter curl or a tracing cat.
Click to expand the full builtin list
cat, cp, file, ln, ls, mkdir, mv, readlink, rm,
rmdir, split, stat, touch, tree
awk, base64, column, comm, cut, diff, expand, fold,
grep (+ egrep, fgrep), head, join, md5sum, nl, od,
paste, printf, rev, rg, sed, sha1sum, sha256sum, sort,
strings, tac, tail, tr, unexpand, uniq, wc, xargs
jq (JSON), sqlite3 (SQLite — opt-in via sqlite subpackage for
the real runtime), xan (CSV), yq (YAML/XML/TOML/CSV)
sqlite3— pure-Gomodernc.org/sqlite, opt-in viasqlite.Registerjs-exec(JavaScript) — plannedpython3/python— planned
gzip (+ gunzip, zcat), tar
basename, cd, dirname, du, echo, env, export, find,
hostname, printenv, pwd, tee
alias, bash, chmod, clear, date, expr, false, help,
history, seq, sh, sleep, time, timeout, true, unalias,
which, whoami
curl, html-to-markdown (require network configuration)
All commands support --help for usage information.
- Pipes:
cmd1 | cmd2 - Redirections:
>,>>,2>,2>&1,<, heredocs - Command chaining:
&&,||,; - Variables:
$VAR,${VAR},${VAR:-default}, arrays - Positional parameters:
$1,$2,$@,$# - Glob patterns:
*,?,[...],**(withglobstar) - Brace expansion:
{1..10},{a,b,c} - Command substitution:
$(cmd),`cmd` - Process substitution:
<(cmd),>(cmd) - If statements:
if COND; then CMD; elif COND; then CMD; else CMD; fi - Functions:
function name { ... }orname() { ... } - Local variables:
local VAR=value - Loops:
for,while,until - Symbolic links:
ln -s target link - Hard links:
ln target link - Source /
.:source script.sh,. script.sh - Eval:
eval "..." - Aliases:
alias name='cmd', controlled byshopt expand_aliases - Shell options:
shopt,set -e,set -u,set -o pipefail
b, _ := gobash.New(gobash.BashOptions{
// Initial files seeded into the in-memory FS.
Files: map[string]gobash.FileInit{
"/data/file.txt": {Content: []byte("content")},
},
// Initial environment.
Env: map[string]string{"MY_VAR": "value"},
// Starting directory (default: /home/user).
Cwd: "/app",
// Execution limits — see "Execution Protection" below.
ExecutionLimits: &gobash.ExecutionLimits{
MaxCallDepth: intPtr(50),
},
})
// Per-Exec overrides.
b.Exec(ctx, "echo $TEMP", gobash.ExecOptions{
Env: map[string]string{"TEMP": "value"},
Cwd: "/tmp",
})
// Pass stdin to the script.
b.Exec(ctx, "cat", gobash.ExecOptions{
Stdin: strings.NewReader("hello from stdin\n"),
})
// Start with a clean environment.
b.Exec(ctx, "env", gobash.ExecOptions{
ReplaceEnv: true,
Env: map[string]string{"ONLY": "this"},
})
// Pass arguments without shell escaping (like spawnSync).
b.Exec(ctx, "grep", gobash.ExecOptions{
Args: []string{"-r", "TODO", "src/"},
})
// Cancel long-running scripts via context.
ctx, cancel := context.WithTimeout(parent, 5*time.Second)
defer cancel()
b.Exec(ctx, "while true; do sleep 1; done", gobash.ExecOptions{})date defaults to UTC (%Z=UTC, %z=+0000) regardless of the host
clock, so the sandbox does not leak the host timezone. To opt into a
specific zone, pass TZ as an initial env var:
b, _ := gobash.New(gobash.BashOptions{
Env: map[string]string{"TZ": "America/New_York"},
})
b.Exec(ctx, "date", gobash.ExecOptions{})
// Mon Jun 1 09:30:00 EDT 2026-u always forces UTC; an unset or invalid $TZ falls back to UTC.
Setting TZ exposes that timezone to scripts running in the sandbox,
so only pass a value you are comfortable revealing — forwarding the
host's real $TZ (e.g. os.Getenv("TZ")) reintroduces the
disclosure that the UTC default exists to prevent.
| Option | Type | Description |
|---|---|---|
Env |
map[string]string |
Environment variables for this execution only |
Cwd |
string |
Working directory for this execution only |
Stdin |
io.Reader |
Standard input passed to the script |
Stdout |
io.Writer |
Where stdout goes; nil → captured into ExecResult.Stdout |
Stderr |
io.Writer |
Where stderr goes; nil → captured into ExecResult.Stderr |
Args |
[]string |
Additional argv passed directly to the first command (bypasses shell parsing; does not change $1, $2, ...) |
ReplaceEnv |
bool |
Start with empty env instead of merging (default: false) |
RawScript |
bool |
Skip leading-whitespace normalization (default: false) |
Cancellation is via context.Context — the first argument to Exec.
Four filesystem implementations:
memfs (default) — pure in-memory filesystem, no disk access:
import gobash "github.com/mark3labs/go-bash"
b, _ := gobash.New(gobash.BashOptions{
Files: map[string]gobash.FileInit{
"/data/config.json": {Content: []byte(`{"key": "value"}`)},
// Lazy: called on first read, cached. Never called if written before read.
"/data/large.csv": {Lazy: func(ctx context.Context) ([]byte, error) {
return []byte("col1,col2\na,b\n"), nil
}},
// Remote: fetch on first read.
"/data/remote.txt": {Lazy: func(ctx context.Context) ([]byte, error) {
resp, err := http.Get("https://example.com")
if err != nil { return nil, err }
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}},
},
})overlayfs — copy-on-write over a real directory. Reads come from
disk, writes stay in memory:
import (
gobash "github.com/mark3labs/go-bash"
"github.com/mark3labs/go-bash/fs/overlayfs"
)
overlay, _ := overlayfs.New(overlayfs.Options{Root: "/path/to/project"})
b, _ := gobash.New(gobash.BashOptions{FS: overlay, Cwd: "/path/to/project"})
b.Exec(ctx, "cat package.json", gobash.ExecOptions{}) // reads from disk
b.Exec(ctx, `echo "modified" > package.json`, gobash.ExecOptions{}) // in memoryrwfs — direct read-write access to a real directory. Use this
if you want the agent to be able to write to your disk:
import "github.com/mark3labs/go-bash/fs/rwfs"
rw, _ := rwfs.New(rwfs.Options{Root: "/path/to/sandbox"})
b, _ := gobash.New(gobash.BashOptions{FS: rw})
b.Exec(ctx, `echo "hello" > file.txt`, gobash.ExecOptions{}) // writes to real diskKeep rwfs pointed at a workspace directory, not at the installed
go-bash module or any other trusted runtime code. Guest-writable
roots should stay separate from trusted code.
mountfs — mount multiple filesystems at different paths.
Combines read-only and read-write filesystems into a unified
namespace:
import (
"github.com/mark3labs/go-bash/fs/memfs"
"github.com/mark3labs/go-bash/fs/mountfs"
"github.com/mark3labs/go-bash/fs/overlayfs"
"github.com/mark3labs/go-bash/fs/rwfs"
)
// Set up the base + mounts.
ro, _ := overlayfs.New(overlayfs.Options{Root: "/path/to/knowledge", ReadOnly: true})
rw, _ := rwfs.New(rwfs.Options{Root: "/path/to/workspace"})
m, _ := mountfs.New(mountfs.Options{
Base: memfs.New(),
Mounts: []mountfs.Mount{
{Path: "/mnt/knowledge", FileSystem: ro},
{Path: "/home/agent", FileSystem: rw},
},
})
b, _ := gobash.New(gobash.BashOptions{FS: m, Cwd: "/home/agent"})
b.Exec(ctx, "ls /mnt/knowledge", gobash.ExecOptions{}) // from knowledge base
b.Exec(ctx, "cp /mnt/knowledge/doc.txt ./", gobash.ExecOptions{}) // cross-mount copy
b.Exec(ctx, `echo "notes" > notes.txt`, gobash.ExecOptions{}) // writes to workspaceNetwork access is disabled by default. Enable it with the Network
option:
import "github.com/mark3labs/go-bash/network"
// Allow specific URLs with GET/HEAD only (safest).
b, _ := gobash.New(gobash.BashOptions{
Network: &network.Config{
AllowedURLPrefixes: []network.AllowedURLEntry{
{URL: "https://api.github.com/repos/myorg/"},
{URL: "https://api.example.com"},
},
},
})
// Allow specific URLs with additional methods.
b, _ = gobash.New(gobash.BashOptions{
Network: &network.Config{
AllowedURLPrefixes: []network.AllowedURLEntry{
{URL: "https://api.example.com"},
},
AllowedMethods: []string{"GET", "HEAD", "POST"}, // default: ["GET", "HEAD"]
},
})
// Inject credentials via header transforms (secrets never enter the sandbox).
b, _ = gobash.New(gobash.BashOptions{
Network: &network.Config{
AllowedURLPrefixes: []network.AllowedURLEntry{
{URL: "https://public-api.com"}, // no transforms
{
URL: "https://ai-gateway.vercel.sh",
Transform: []network.RequestTransform{{
Headers: map[string]string{
"Authorization": "Bearer " + os.Getenv("API_TOKEN"),
},
}},
},
},
},
})
// Allow all URLs and methods (use with caution).
b, _ = gobash.New(gobash.BashOptions{
Network: &network.Config{DangerouslyAllowFullAccess: true},
})Note: The curl command exists in the registry whether or not
network is configured. With no network config, curl returns
"network disabled" and exits non-zero.
The allow-list enforces:
- Origin matching: URLs must match the exact origin (scheme + host + port)
- Path prefix: Only paths starting with the specified prefix are allowed (case-sensitive)
- HTTP method restrictions: Only
GETandHEADby default (configureAllowedMethodsfor more) - Redirect protection: Redirects to non-allowed URLs are blocked; the allow-list is re-checked at every hop
- Header transforms: Headers in
Transformare injected at the fetch boundary and override any user-supplied headers with the same name, preventing credential substitution from inside the sandbox. Headers are re-evaluated on each redirect so credentials are never leaked to non-transform hosts. - Private-range deny: When
DenyPrivateRanges: true, the resolver rejects RFC-1918 / link-local / loopback addresses before dial, blocking SSRF to cloud metadata endpoints.
# Fetch and process data
curl -s https://api.example.com/data | grep pattern
# Download and convert HTML to Markdown
curl -s https://example.com | html-to-markdown
# POST JSON data
curl -X POST -H "Content-Type: application/json" \
-d '{"key":"value"}' https://api.example.com/endpointThe sqlite3 builtin ships as a stub in the default registry. For the
real runtime (pure-Go modernc.org/sqlite), import the sqlite
subpackage and call Register:
import gbsqlite "github.com/mark3labs/go-bash/sqlite"
b, _ := gobash.New(gobash.BashOptions{})
_ = gbsqlite.Register(b, gbsqlite.Options{Timeout: 5 * time.Second})
// Query in-memory database
b.Exec(ctx, `sqlite3 :memory: "SELECT 1 + 1"`, gobash.ExecOptions{})
// Query file-based database
b.Exec(ctx, `sqlite3 data.db "SELECT * FROM users"`, gobash.ExecOptions{})File DBs shuttle through os.MkdirTemp for the query duration and
write back to the VFS via c.FS.WriteFile on cleanup. Concurrent
writers race the cleanup; last writer wins.
Queries run with a configurable timeout (default 5 s) enforced via
context.WithTimeout + a goroutine that closes the DB on
ctx.Done().
Planned. When implemented, the
jsexec/ subpackage will provide js-exec backed by
github.com/dop251/goja (pure Go, no cgo). The TS port uses QuickJS;
the Go port will use goja for the same CGO_ENABLED=0 guarantee.
Planned. Unlike just-bash (which
embeds CPython compiled to WASM), go-bash will NOT embed CPython.
The pythonexec/ subpackage will expose a Runtime interface that
the host implements — typically by routing to docker run python:3.13
or a real python3 binary the host opts into. This is an
intentional divergence; see DECISIONS.md for the rationale.
Parse bash scripts into an AST, transform them, and serialize back to bash. Good for instrumenting scripts (e.g., capturing per-command stdout/stderr) or extracting metadata before execution.
import (
gobash "github.com/mark3labs/go-bash"
"github.com/mark3labs/go-bash/transform"
"github.com/mark3labs/go-bash/transform/plugins/collector"
"github.com/mark3labs/go-bash/transform/plugins/tee"
)
// Standalone pipeline — output can be run by any shell.
pipeline := transform.New()
pipeline.Use(tee.New(tee.Options{OutputDir: "/tmp/logs"}))
pipeline.Use(collector.New())
result, _ := pipeline.Transform("echo hello | grep hello")
result.Script // transformed bash string
result.Metadata["command-collector"] // {"commands": ["echo", "grep", "tee"]}
// Integrated API — Exec() auto-applies transforms and returns metadata.
b, _ := gobash.New(gobash.BashOptions{})
b.RegisterTransformPlugin(collector.New())
res, _ := b.Exec(ctx, "echo hello | grep hello", gobash.ExecOptions{})
res.Metadata["command-collector"] // {"commands": ["echo", "grep"]}See the package docs for the full API, built-in plugins, and how to write custom plugins.
The sandbox/ subpackage is a drop-in replacement for
@vercel/sandbox — same
API surface, but runs entirely in-process with the virtual filesystem.
Start with go-bash for development and testing, swap in a real
sandbox when you need a full VM.
import "github.com/mark3labs/go-bash/sandbox"
sb, _ := sandbox.Create(ctx, sandbox.Options{Cwd: "/app"})
// Write files to the virtual filesystem.
sb.WriteFiles(ctx, map[string]string{
"/app/script.sh": `echo "Hello World"`,
"/app/data.json": `{"key": "value"}`,
})
// Run commands and get results.
cmd, _ := sb.RunCommand(ctx, sandbox.RunCommandParams{
Cmd: "bash",
Args: []string{"/app/script.sh"},
})
stdout, _ := cmd.Stdout() // "Hello World\n"
fin, _ := cmd.Wait()
fmt.Println(fin.ExitCode) // 0
// Read files back.
content, _ := sb.ReadFile(ctx, "/app/data.json")
// Create directories.
sb.MkDir(ctx, "/app/logs", sandbox.MkDirOptions{Recursive: true})
// Clean up (no-op for Bash, but API-compatible).
sb.Stop(ctx)Install globally for a sandboxed CLI:
go install github.com/mark3labs/go-bash/cmd/gobash@latest
# Execute inline script.
gobash -c 'ls -la && cat package.json | head -5'
# Execute with specific project root.
gobash -c 'grep -r "TODO" src/' --root /path/to/project
# Pipe script from stdin.
echo 'find . -name "*.go" | wc -l' | gobash
# Execute a script file.
gobash ./scripts/deploy.sh
# Get JSON output for programmatic use.
gobash -c 'echo hello' --json
# {"stdout":"hello\n","stderr":"","exitCode":0}The CLI uses overlayfs — reads come from the real filesystem, but all
writes stay in memory and are discarded after execution (unless
--allow-write is supplied).
Important: The project root is mounted at /home/user/project.
Use this path (or relative paths from the default cwd) to access your
files inside the sandbox.
Options:
| Flag | Description |
|---|---|
-c <script> |
Execute script from argument |
--root <path> |
Root directory (default: current directory) |
--cwd <path> |
Working directory in sandbox (default: project mount point) |
--allow-write |
Allow write operations (default: read-only) |
--python |
Accept the flag for just-bash compat (no-op until Phase 16) |
--javascript |
Accept the flag for just-bash compat (no-op until Phase 15) |
-e, --errexit |
Exit on first error |
--json |
Output as JSON |
--no-network |
Explicitly disable network (default) |
--network-allow PREFIX |
Allow network requests to PREFIX (repeatable; go-bash-specific) |
-h, --help |
Show help |
-v, --version |
Show version |
The flags are a strict superset of just-bash's CLI — every
invocation that works against just-bash works against gobash,
with go-bash-specific additions (--no-network, --network-allow)
documented above.
go-bash protects against infinite loops, deep recursion, and runaway output with configurable limits:
b, _ := gobash.New(gobash.BashOptions{
ExecutionLimits: &gobash.ExecutionLimits{
MaxCallDepth: intPtr(100),
MaxCommandCount: intPtr(10000),
MaxLoopIterations: intPtr(10000),
MaxAwkIterations: intPtr(10000),
MaxSedIterations: intPtr(10000),
},
})All limits have defaults. Error messages tell you which limit was hit
via *gobash.ExecutionLimitError — use errors.As to inspect.
| Field | Default |
|---|---|
MaxCallDepth |
100 |
MaxCommandCount |
10 000 |
MaxLoopIterations |
10 000 |
MaxAwkIterations |
10 000 |
MaxSedIterations |
10 000 |
MaxJqIterations |
10 000 |
MaxSqliteTimeout |
5 s |
MaxPythonTimeout |
10 s |
MaxJsTimeout |
10 s |
MaxGlobOperations |
100 000 |
MaxStringLength |
10 MiB |
MaxArrayElements |
100 000 |
MaxHeredocSize |
10 MiB |
MaxSubstitutionDepth |
50 |
MaxBraceExpansionResults |
10 000 |
MaxOutputSize |
10 MiB |
MaxFileDescriptors |
1024 |
MaxSourceDepth |
100 |
- The shell only has access to the provided filesystem.
- All execution happens in-process, without VM isolation. The
code base is designed to be robust against the structural attacks
that JavaScript-based sandboxes have to defend against (prototype
pollution,
eval, dynamicimport()) — those vectors do not exist in Go. - There is no network access by default. When enabled, requests are checked against URL prefix allow-lists and HTTP-method allow-lists at every hop.
- Python and JavaScript execution are off by default — those are opt-in subpackages that represent additional security surface.
- Execution is protected against infinite loops, deep recursion, and runaway output with configurable limits.
os/execis not imported in the runtime package — no code path exists from the script to a host process.- Use a real container (Vercel Sandbox, Firecracker, etc.) if you need a full VM with arbitrary binary execution.
See THREAT_MODEL.md for the full threat model, including a "What Go gives us for free" section explaining the structural advantages over the TypeScript reference implementation.
When created without options, Bash provides a Unix-like directory
structure:
/home/user— default working directory (and$HOME)/bin— contains stubs for all built-in commands/usr/bin— additional binary directory/tmp— temporary files directory/etc/hostname— synthesized hostname/proc/self/status— synthesized procfs entry with virtualized PID / UID
Commands can be invoked by path (e.g., /bin/ls) or by name.
For AI agents wiring go-bash into a tool-use loop, see
AGENTS.md — it covers the standard agent-tool setup,
filesystem choices, network policy, custom commands, and common
failure modes you'll want to surface back to the model.
go-bash is feature-complete for its core runtime, builtins, sandbox API, and CLI:
| Area | Status |
|---|---|
| Runtime + builtins | Complete |
| SQLite runtime | Complete |
| Sandbox API | Complete |
| CLI | Complete |
| JavaScript runtime (via goja) | Deferred |
| Python runtime (via host hook) | Pending |
If you're working on go-bash internals (not using it as a library),
start with AGENTS.md and the package-level docs.
MIT. See LICENSE.