This file is the agent-facing operating guide for go-bash. If you are
an AI agent (Claude, GPT, Gemini, etc.) wiring go-bash into a tool-call
loop, read this in full before issuing any Exec calls. If you are a
human contributor working on go-bash internals, read the package-level
docs and DECISIONS.md instead.
go-bash is an in-process, sandboxed bash environment written in Go.
Scripts run inside a virtual filesystem with no host disk access by
default and no network access by default. The runtime is byte-compatible
with just-bash (the TypeScript original) for the same set of bash
constructs and built-in commands.
Concretely, you call bash.Exec(ctx, "echo hi", ExecOptions{}) from
Go. The script never reaches the host shell, never reaches os/exec,
and (by default) never reaches the host filesystem. You get back
stdout, stderr, and an exit code as strings.
import gobash "github.com/mark3labs/go-bash"
b, _ := gobash.New(gobash.BashOptions{})
res, _ := b.Exec(ctx, "echo hello", gobash.ExecOptions{})
// res.Stdout == "hello\n", res.ExitCode == 0Use go-bash when you want:
- A bash environment an LLM can write scripts against without you trusting the host shell.
- Per-call file isolation — writes vanish when the call returns
(with
memfs) or stay scoped to an overlay (withoverlayfs). - Deterministic limits — every limit in
ExecutionLimitsis enforced before runaway scripts can DoS your process. - A test harness for bash-using prompts without needing a container.
Use os/exec directly when you want:
- To launch a real long-running binary (
ffmpeg,git, etc.). - To talk to the host kernel (mounting, raw sockets, ptrace).
- The full real-bash surface (job control, signals,
disown, etc.).
go-bash deliberately does NOT cover those cases.
A typical AI-agent integration looks like this:
import (
"context"
gobash "github.com/mark3labs/go-bash"
"github.com/mark3labs/go-bash/network"
)
func newAgentBash() (*gobash.Bash, error) {
return gobash.New(gobash.BashOptions{
// Seed initial files the model can read.
Files: map[string]gobash.FileInit{
"/work/README.md": {Content: []byte("...")},
},
// Start in /work so relative paths feel natural.
Cwd: "/work",
// Allow network only to your AI gateway or known APIs.
Network: &network.Config{
AllowedURLPrefixes: []network.AllowedURLEntry{
{URL: "https://api.example.com/v1"},
},
},
})
}
func toolBashExec(ctx context.Context, b *gobash.Bash, script string) string {
res, err := b.Exec(ctx, script, gobash.ExecOptions{})
if err != nil {
return "ERROR: " + err.Error()
}
return res.Stdout + res.Stderr
}Wire toolBashExec into your tool registry (Anthropic tool-use, OpenAI
function-calling, MCP, the AI SDK). The model sees a single bash tool;
you control what it can touch.
When called with BashOptions{}:
- Filesystem. A fresh in-memory FS (
memfs)./home/useris the default cwd./bin/<cmd>stubs exist for every registered builtin./etc/hostname,/proc/self/statusare populated. - Builtins. Every Wave A–H builtin (~140 commands):
cat,ls,grep,sed,awk,jq,cut,sort,uniq,tar,gzip,base64,find,xargs,bash,sh,eval,source,.,timeout,printf,date(UTC by default), and many more. - Shell features. Pipes, redirections (
>,>>,<,2>&1, heredocs),&&/||/;, variables ($VAR,${VAR:-default}, arrays), functions,for/while/untilloops,if/case, glob expansion, brace expansion ({1..10}), command substitution ($(...)), parameter expansion, process substitution (<(...)). - Limits. Loop iterations, call depth, command count, output size, string length, brace expansion size — all enforced by default.
What the model cannot do by default:
- No network.
curl https://example.comreturns "network disabled" until you supplyBashOptions.Network. - No host disk.
cat /etc/passwdreturnsNo such file or directory— the VFS contains only what you seeded. - No
os/exec. There is no path from the script to a host binary. Unknown commands return exit 127 (command not found). - No Python / JavaScript runtimes. Those are opt-in subpackages (see "Optional capabilities" below) and not built into the default binary.
Four FS implementations ship; pick one per agent session.
Examples:
// Read project read-only, capture writes in memory.
overlay, _ := overlayfs.New(overlayfs.Options{Root: "/path/to/project"})
b, _ := gobash.New(gobash.BashOptions{FS: overlay, Cwd: "/path/to/project"})
// Let the agent write to a scratch dir on disk.
rw, _ := rwfs.New(rwfs.Options{Root: "/tmp/agent-scratch"})
b, _ := gobash.New(gobash.BashOptions{FS: rw})
// Read-only knowledge base + writable workspace.
m, _ := mountfs.New(mountfs.Options{Base: memfs.New()})
ro, _ := overlayfs.New(overlayfs.Options{Root: "/kb", ReadOnly: true})
rw, _ := rwfs.New(rwfs.Options{Root: "/tmp/workspace"})
m.Mount("/mnt/kb", ro)
m.Mount("/home/agent", rw)
b, _ := gobash.New(gobash.BashOptions{FS: m, Cwd: "/home/agent"})rwfs is the only mode that touches the host disk. Point it at a
scratch directory, never at the project root or the directory
containing your agent binary.
Network is off by default. Enable per-host:
import "github.com/mark3labs/go-bash/network"
b, _ := gobash.New(gobash.BashOptions{
Network: &network.Config{
AllowedURLPrefixes: []network.AllowedURLEntry{
// Public read-only API — no auth.
{URL: "https://api.github.com/repos/myorg/"},
// Authed API — inject the bearer at the fetch boundary
// so the token never enters the sandbox script context.
{
URL: "https://api.openai.com/v1",
Transform: []network.RequestTransform{{
Headers: map[string]string{
"Authorization": "Bearer " + os.Getenv("OPENAI_API_KEY"),
},
}},
},
},
AllowedMethods: []string{"GET", "POST"},
Timeout: 30 * time.Second,
MaxResponseSize: 10 * 1024 * 1024,
},
})The allow-list enforces:
- Exact origin match (
scheme://host:port) - Path-prefix match (case-sensitive)
- Method allow-list (default
GET,HEADonly) - Per-redirect re-validation (no SSRF via 302)
- Headers from
Transformoverride any header the script set with the same name — credentials are unreachable from inside the script.
For raw escape, DangerouslyAllowFullAccess: true opens everything.
Never combine that with a script source you don't trust.
Every limit in ExecutionLimits has a default. Tighten them when an
agent's tool loop is short; loosen them only with reason.
b, _ := gobash.New(gobash.BashOptions{
ExecutionLimits: &gobash.ExecutionLimits{
MaxLoopIterations: intPtr(1000),
MaxOutputSize: intPtr(64 * 1024),
},
})Defaults (full table in README.md):
| Limit | Default |
|---|---|
| MaxCallDepth | 100 |
| MaxCommandCount | 10000 |
| MaxLoopIterations | 10000 |
| MaxOutputSize | 10 MiB |
| MaxStringLength | 10 MiB |
| MaxHeredocSize | 10 MiB |
| MaxJqIterations | 10000 |
| MaxAwkIterations | 10000 |
When a limit trips, Exec returns an *ExecutionLimitError you can
test with errors.As. Surface the limit name back to the model so it
can retry with a smaller script.
| Capability | Package | Built into default binary? |
|---|---|---|
sqlite3 (pure-Go modernc.org/sqlite) |
github.com/mark3labs/go-bash/sqlite |
Yes (Wave H stub); call sqlite.Register for the real runtime |
JavaScript (js-exec via goja) |
NOT YET BUILT | No — Phase 15 deferred |
Python (python3 via host hook) |
NOT YET BUILT | No — Phase 16 deferred |
To enable the SQLite real runtime:
import gbsqlite "github.com/mark3labs/go-bash/sqlite"
b, _ := gobash.New(gobash.BashOptions{})
_ = gbsqlite.Register(b, gbsqlite.Options{Timeout: 5 * time.Second})
b.Exec(ctx, `sqlite3 :memory: "SELECT 1 + 1"`, gobash.ExecOptions{})ExecOptions carries per-call overrides:
res, _ := b.Exec(ctx, "echo $TEMP", gobash.ExecOptions{
Env: map[string]string{"TEMP": "value"}, // merged on top
Cwd: "/tmp",
Stdin: strings.NewReader("piped input"),
ReplaceEnv: false, // true = wipe parent env
Args: []string{"extra", "argv"}, // passed to first cmd
})Env merges on top of the per-Bash env by default; ReplaceEnv: true
wipes it first. Env mutations from export survive across calls
unless you set ReplaceEnv.
The first parameter is always context.Context. Cancel the context
to abort a runaway script:
ctx, cancel := context.WithTimeout(parent, 5*time.Second)
defer cancel()
res, err := b.Exec(ctx, untrustedScript, gobash.ExecOptions{})
// On timeout: err is context.DeadlineExceeded.Cancellation is cooperative — the runtime checks ctx at every
statement boundary. A pathological tight loop in a single builtin
(e.g. awk) may take a full iteration to notice; the iteration
limits cover that case.
Register Go commands the script can call by name:
import "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}
})
b, _ := gobash.New(gobash.BashOptions{
CustomCommands: []command.Command{hello},
})
b.Exec(ctx, "hello Alice", gobash.ExecOptions{})
// stdout: "Hello, Alice!\n"Custom commands receive a command.Context with FS, Cwd, Env,
Stdin, Stdout, Stderr, Fetch (the network Doer), and Exec
(for sub-shell invocation). They participate in pipes, redirections,
and exit-code semantics like any builtin.
Custom commands override builtins with the same name — useful for
swapping in a stricter curl or a tracing cat.
If your existing code targets @vercel/sandbox,
the sandbox subpackage wraps *Bash in the same surface:
import "github.com/mark3labs/go-bash/sandbox"
sb, _ := sandbox.Create(ctx, sandbox.Options{
OverlayRoot: "/path/to/project",
})
cmd, _ := sb.RunCommand(ctx, sandbox.RunCommandParams{
Cmd: "ls",
Args: []string{"-la"},
})
stdout, _ := cmd.Stdout()
fin, _ := cmd.Wait()
// fin.ExitCode is the script's exit code.Same security model as the underlying *Bash. No real container, no
process isolation — just the VFS + the network allow-list.
The gobash binary is a thin shell around the library. Useful for
shell scripts and as an executable tool you can hand to other agents:
go install github.com/mark3labs/go-bash/cmd/gobash@latest
gobash -c 'echo hello'
gobash --root /path/to/project -c 'ls -la'
gobash --json -c 'echo a; echo b 1>&2; exit 3'
# {"stdout":"a\n","stderr":"b\n","exitCode":3}The CLI flags are a strict superset of just-bash's — invocations
that work against just-bash also work here. See README.md for
the full flag list.
When wiring go-bash into a tool-use loop, the most useful single-paragraph description for the system prompt is:
You have access to a
bashtool that runs scripts in a sandboxed in-memory Linux environment. The filesystem starts empty (or contains the files I provided); writes do not persist outside the session unless I configured a writable mount. There is no network unless explicitly allowed. You can use every standard Unix command you know:cat,grep,sed,awk,jq,find,tar, etc. Background jobs (&) andwaitwork synchronously. The environment is byte-compatible with bash for the commands and syntax it supports.
When surfacing failures to the model, include the limit name and the trigger condition — models recover well when told exactly which knob they hit.
Read THREAT_MODEL.md for the full version. Short summary:
- Untrusted scripts cannot: read the host disk, make network
calls outside the allow-list, spawn host processes, read your
environment variables, escape via prototype pollution, escape via
eval(noevalreaches Go code —evalin bash stays inside the bash interpreter). - Untrusted scripts CAN: consume CPU and memory up to the
configured limits, write arbitrary content into the VFS (which
vanishes with the
*Bash), read whatever you seeded into the VFS withFilesorFS. - You ARE responsible for: keeping
rwfspointed at a scratch directory, not feeding the model youros.Getenvdirectly, and tightening limits below the defaults for short-lived agent loops.
The host hooks you pass in (CustomCommands, Fetch, Logger,
InvokeTool) run with full Go privileges — they are trusted code by
definition. Don't load them from untrusted sources at runtime.
go-bash is MIT licensed.
go-bash is beta software. The public surface is stable, but the
byte-level output of some builtins may shift as we close gaps against
real bash. Pin a version in go.mod.
