sbsh is a sandboxed shell/runtime for AI agents. It gives agents a constrained environment for safe local work on mounted files and approved network destinations. Instead of exposing the host shell directly, sbsh provides a predictable execution boundary for investigation, transformation, and lightweight automation. It is designed for embedding into agentic tools and AI systems that need controlled shell-like execution.
sbsh runs shell scripts — pipelines, redirections, globs, heredocs, functions, and Python — without ever spawning a host process.
Commands are Go functions against a virtual filesystem; Python is CPython compiled to WebAssembly and run under wazero.
There is no fork, no exec, and no PATH lookup on the host.
Two things reach the host, and both are closed by default:
| Boundary | Opened by | Enforced by |
|---|---|---|
| Filesystem | host directory mounts | os.Root (openat2 + RESOLVE_BENEATH semantics) |
| Network | an allow list of hosts, IPs, and CIDRs | a policy-checked http.Client |
Everything else has no route out by construction: no subprocess is ever created, and the Wasm module is instantiated with a filesystem mount and nothing else.
$ sbsh --mount ./work:/work --deny-path '**/.env' --allow-net '*.githubusercontent.com'
sbsh REPL (Ctrl-D to exit)
$ ls /work
.env
data.csv
$ cut -d, -f1 /work/data.csv | sort -r
b
a
$ cat /work/.env
cat: open /work/.env: permission denied
(exit code 1)
$ python -c 'import sys; print(sys.version.split()[0])'
3.14.6go install github.com/mrtc0/sbsh/cmd/sbsh@latestRequirements: Go 1.25+, and nothing else.
sbsh [flags]
-c, --command string Run a script once and exit
--mount stringArray HOST:VPATH[:ro] — expose a host directory at a virtual path
--deny-path stringArray Refuse access to paths matching PATTERN
--allow-net stringArray Allow network access to a host name, "*." wildcard, IP, or CIDR
--timeout string Stop a script that runs longer than this, e.g. "500ms", "30s", "1m"
("0" removes the deadline; default "30s")
--output-limit string Capture at most this many bytes of stdout and of stderr
per script (default 4194304, 4 MiB)
-v, --version Print the version
With no -c, sbsh starts a REPL. On a terminal it provides line editing and
in-session history; when stdin is a pipe it reads line by line, so an agent can
feed it a script directly. -c runs one script and exits with the script's
status.
SIGINT stops the running script and leaves the REPL at its prompt; -c exits
130. SIGTERM stops the script and the process, which exits 143. Either way
the sandbox is closed on the way out. Pressing Ctrl-C at a terminal is a separate
matter — see Not implemented below.
A Sandbox is one long-lived shell session. Working directory, variables, and
functions persist across Exec calls, and calls are serialized.
package main
import (
"context"
"fmt"
"log"
"time"
"github.com/mrtc0/sbsh/sandbox"
)
func main() {
ctx := context.Background()
sb, err := sandbox.New(ctx,
sandbox.WithHostMountRW("./work", "/work"),
sandbox.WithDenyPaths("**/.env", "/work/secrets"),
sandbox.WithNetworkAllow("*.githubusercontent.com"),
sandbox.WithEnv("TZ", "UTC"),
sandbox.WithTimeout(10*time.Second),
sandbox.WithOutputLimit(1<<20),
)
if err != nil {
log.Fatal(err)
}
defer sb.Close()
res, err := sb.Exec(ctx, `cd /work && wc -l data.csv && python -c 'print(1+1)'`, nil)
if err != nil {
log.Fatal(err) // a sandbox-level failure, not a script failure
}
fmt.Printf("exit=%d truncated=%v\n%s", res.ExitCode, res.Truncated, res.Stdout)
}sandbox.Result is exec.Result from sandbox/exec: one shape for every
execution in the sandbox, so an execution a command starts from inside the
sandbox reports back the same way a host's Exec does.
A result is always populated, including for the endings that come with an
error, and Result.Outcome says why the execution ended. Machine meaning lives
in that field, not in the wording of a stderr line; stderr stays what a person
reads.
| Outcome | Exit code | Meaning | error |
|---|---|---|---|
OutcomeCompleted |
the request's own | Ran to completion and picked its own status | nil |
OutcomeNotFound |
127 | The run ended on an unresolved command name | nil |
OutcomeDenied |
126 | The sandbox refused the request; the reason is on stderr | nil |
OutcomeTimedOut |
137 | The deadline passed and the run was stopped | nil |
OutcomeCanceled |
130 | The caller's context was cancelled | nil |
OutcomeInvalid |
2 | The request could never become a run — a script that does not parse | non-nil |
OutcomeInternal |
125 | The sandbox itself failed; the status says nothing | non-nil |
OutcomeUnsupported |
126 | The runtime has no way to carry the request out; stderr says what to use instead | nil |
An error therefore means one of two things only: the request could not be made
sense of, or the sandbox is at fault. Everything else — a failing script, an
unknown command, a refusal, a limit the caller asked for — is a normal outcome
with a nil error. Result.OK() is the one check for "did this work", and
Result.Stopped() for "a limit ended it".
Result.Truncated is independent of the outcome: a run that wrote past the
output limit still completed, so a caller that must see everything has to treat
truncation as a failure of its own.
A denial a command runs into while it works — a path WithDenyPaths covers, a
destination the network policy does not allow — is not OutcomeDenied. That is
the command's own failure, reported the way it reports any other: a status and a
diagnostic. OutcomeDenied is for a request the sandbox would not start.
OutcomeUnsupported is the neighbouring case: a request this runtime cannot
carry out rather than one it refuses. Calling Exec from inside the sandbox is
the example — a single shell session cannot re-enter itself, and no permission is
being decided — so a caller reporting a refusal to a user does not tell them a
policy stopped them when nothing did.
A command running inside the sandbox reports back with the same contract, which
is why classification lives in sandbox/exec rather than in Exec — see
Nested execution.
| Option | Effect |
|---|---|
WithHostMountRO(hostDir, vpath) |
Mount a host directory read-only |
WithHostMountRW(hostDir, vpath) |
Mount a host directory read-write |
WithMountRO(vpath, afero.Fs) |
Mount any afero.Fs read-only |
WithMountRW(vpath, afero.Fs) |
Mount any afero.Fs read-write |
WithDenyPaths(patterns...) |
Refuse access to matching paths, on top of the mounts |
WithNetworkAllow(entries...) |
Allow outbound access to the listed destinations |
WithEnv(k, v) |
Add an environment variable |
WithTimeout(d) |
Wall-clock limit per Exec (default 30s) |
WithOutputLimit(n) |
Cap on captured stdout and stderr (default 4 MiB) |
WithCommand(cmds...) |
Register commands written in Go |
A host that needs a command sbsh does not ship writes it in Go and registers it
on the sandbox. A registered command is dispatched exactly like a builtin: it
appears in pipelines, its streams can be redirected, its exit status is the
script's, and an unregistered name still prints command not found. It is not a
second kind of command — a builtin is a command.RunFunc receiving the same
command.Invocation.
The interface is three methods:
type Command interface {
Name() string
Description() string
Run(ctx context.Context, inv *command.Invocation) error
}command.New(name, description, fn) builds one from a function when the command
holds no state:
sb, err := sandbox.New(ctx,
sandbox.WithCommand(command.New("slugify", "lower-case and hyphenate a line",
func(_ context.Context, inv *command.Invocation) error {
b, err := io.ReadAll(inv.Stdin)
if err != nil {
return command.Exitf(1, "read: %v", err)
}
slug := strings.Join(strings.Fields(strings.ToLower(string(b))), "-")
if _, err := fmt.Fprintln(inv.Stdout, slug); err != nil {
return command.Exitf(1, "write: %v", err)
}
return command.Exit(0)
})),
)Invocation is everything the command gets for one call, and nothing in it
mentions the shell:
| Field | What it is |
|---|---|
Name |
The command's own name, for prefixing its diagnostics |
Args |
The arguments, without the name |
Dir |
The working directory; inv.Abs(p) resolves an argument against it |
Stdin, Stdout, Stderr |
The streams the shell wired up — a pipe, a redirect, or captured output |
Env |
The environment as the script sees it, including LANG=C mycmd assignments: Lookup for one variable, All for every one, inv.Getenv when unset and empty need not differ |
FS |
The sandbox filesystem, mounts resolved and deny patterns in force |
HTTP |
The policy-checked client, nil when no network was allowed |
Python |
The sandbox's Python interpreter, the one the python command runs on |
Nested |
The runtime behind inv.RunNested, which runs a script back inside the sandbox — see Nested execution |
Nothing in it is valid after the call returns: the streams belong to the shell, which may be piping them into the next command.
FS, HTTP and Python are the only ways out, and they are exactly the ones
the builtins have. A command that reaches for os or net/http directly steps
outside the sandbox, and registering it does not make that safe.
A command reports back one way, whether it succeeded or not: it returns
command.Exit or command.Exitf, picking its own status and attaching a message
only when the caller should be shown one.
return command.Exit(0) // done, nothing to say
return command.Exit(1) // a status of its own, the way grep reports "no match"
return command.Exit(2, "too many files") // prints "name: too many files" on stderr, exits 2
return command.Exitf(1, "read: %v", err) // the same, with the message formattedThe message travels with the status, so a command need not write the diagnostic
it fails on to inv.Stderr itself — that is for what it reports while it keeps
going. Do not wrap the result either: only the message the ExitError carries is
printed, so fmt.Errorf("bad usage: %w", command.Exit(2)) exits 2 silently. Use
command.Exitf to build the message instead.
Returning any other error is outside the contract. The sandbox has no status to
go by, so it falls back to printing the error and exiting 1.
Registration is per sandbox, not process-wide, so two sandboxes in one program
can offer different commands. It fails at New — rather than silently later —
when a command is nil, its name is not a plain word
([A-Za-z0-9][A-Za-z0-9_.-]*), its description is empty, or the name is already
taken by a builtin, by another registered command, or by something the shell
handles itself (cd, time, if, …). Each of those could never be reached: a
script starting with if does not parse, and time would measure what follows
it rather than run the registration.
Sandbox.Commands() returns the registered commands sorted by name, for a host
that wants to render its own listing; sbsh has no help command of its own.
examples/customcommand is a complete program: a
wordfreq command reading files and standard input, registered and then used in
a script alongside the builtins. Dynamic loading and config-file registration are
not supported — a command added this way is Go code compiled into the host.
A command does not have to reimplement in Go what the sandbox can already do. It
runs a script back inside the sandbox it is running in, the way bash runs
bash:
res, err := inv.RunNested(ctx, command.NestedRequest{
Script: `set -o pipefail; cut -d, -f"$FIELD" -- "$FILE" | sort | uniq -c | sort -rn`,
Env: []string{"FIELD=2", "FILE=" + inv.Args[0]},
})
if err != nil {
return command.Exitf(1, "%v", err) // a bad request, or the sandbox itself
}
if !res.OK() {
return command.Exitf(1, "counting failed: %s", res.Outcome)
}The child runs on the same capability boundary as the caller: the same mounts and
deny patterns, the same network policy, the same registered commands. There is no
route to the host in it, and a NestedRequest can only narrow what the child is
given, never widen it.
It is not Sandbox.Exec. A sandbox is one shell session, and the command is
running inside the execution that holds it, so calling Exec from inside would
wait on the very run it is part of. That call is answered with
OutcomeUnsupported instead of deadlocking. A nested run gets a shell session of
its own, started where the caller stands and discarded when it ends.
| What | Inherited by the child |
|---|---|
| Filesystem, deny patterns, network policy, commands, Python | Yes — the sandbox's, shared |
| Working directory | The caller's inv.Dir, unless Dir names another; a relative Dir resolves against the caller's |
HOME |
Yes — a script has no other way to find it |
PWD |
Set by the sandbox from where the child actually runs |
| The rest of the caller's environment | No — name what the script reads in Env |
Shell variables, functions, set options |
No — a fresh session |
OLDPWD |
No — the child has not been anywhere, so cd - cannot land in the caller's history |
| Standard input | No — a request is a script, not a filter; the child reads an empty stdin |
| Shell state the child leaves behind | No — its cd, its variables and its functions end with it. Files it writes are of course shared |
Env entries are NAME=value pairs, and they are the whole environment the
child's script reads. A child does not inherit the caller's environment because a
command's environment is a record of everything that has happened to the shell —
a variable some earlier command exported, a value the host passed in for another
command's sake — and a script that reads it behaves differently depending on who
called it. Naming what the script needs makes the request say what it depends on.
An entry naming PWD or OLDPWD is dropped; those are the sandbox's to set.
Prefer a variable to string concatenation, as above: a value interpolated into
the script is shell source, so a file named ; rm -rf / would be a second
command. A variable is data whatever it holds.
Timeout and OutputLimit only ever tighten. The caller's deadline stays in
force and the earlier of the two stops the run; a limit above the sandbox's own
is the sandbox's. Output past the limit is discarded and res.Truncated says so,
independently of the outcome — a truncated run still completed.
The result is the same exec.Result a host gets from Exec, so a caller cannot
tell a child's ending from a top-level one by its shape. Read res.Outcome
rather than parsing res.Stderr: OutcomeCompleted with a non-zero status is
the script's own failure, OutcomeDenied a request the sandbox refused,
OutcomeTimedOut or OutcomeCanceled a limit, OutcomeUnsupported a sandbox
with no nested execution at all. The error is non-nil only for a request that
makes no sense — an empty script, a working directory that is not there — and for
a failure of the sandbox itself; a child that exits non-zero is not an error, any
more than it is for the host.
Nesting is bounded at 8 levels; past that a request reports OutcomeDenied
rather than running. Composing a handful of commands is what this is for, and a
command that invokes itself is a script away.
What v1 leaves out, deliberately:
- Streaming. The child's output is captured and returned when it ends;
nothing is written to
inv.Stdoutas it goes. - Piping into a child. There is no stdin to give it, and no way to run a
child as a stage of the caller's pipeline. Pass data through a file or through
Env. - Loosening anything. No request can add a mount, a network destination, more time, or more output than the caller already has.
- Re-entering
Sandbox.Exec. It is the host's entry point; from inside it reportsOutcomeUnsupportedand names the execution already running.
examples/nestedcommand is a complete program: a
tally command that counts a CSV column by composing cut, sort and uniq
inside the sandbox instead of in Go.
Mounts decide what exists and whether it is writable. Deny patterns layer on top
and close read and write together — the case a read-only mount cannot express,
such as .env inside an otherwise writable project.
sbsh --mount ./project:/work \
--mount ~/.cache/pip:/cache:ro \
--deny-path '**/.env' \
--deny-path '/work/secrets'Pattern syntax is deliberately small:
*matches within a single path segment and never matches/.**matches zero or more whole segments and must stand alone as a segment.- A pattern that does not start with
/is anchored at any depth:.envmeans**/.env. A pattern starting with/is anchored at the root. ?,[...], and\are rejected at parse time rather than silently ignored.
Without --allow-net / WithNetworkAllow the sandbox has no network at all. No
HTTP client is constructed, and curl exits 1 with
network access is not permitted.
An allow-list entry is a host name (example.com), a leading-wildcard host name
(*.github.com — subdomains at any depth, but not github.com itself), an IP
address (192.168.1.1), or a CIDR block (10.0.1.0/24).
A connection is allowed when either check passes:
- The name matches a host entry, when the request is made.
- The address matches an address entry, when the connection is opened — the dialer resolves the name itself, checks every address it got back, and connects to that exact address. A name allowed this way cannot be pointed somewhere else between the check and the connection.
The second check is what lets --allow-net 10.0.1.0/24 on its own permit
curl https://example.com when that name lands inside the block. Redirects are
covered for free: each hop opens a new connection and so goes through the same
check.
None of these is a host binary. Each is a Go function handed the sandbox filesystem. A host can add commands of its own in Go — see Custom commands.
The shell language is mrtc0/sh, a
patched fork of mvdan.cc/sh: pipelines,
redirections, heredocs, globs, variables, functions, and source.
Three commands come from an existing implementation:
The rest are written in this repository on top of the Go standard library, each covering a subset of the flags its original accepts:
curl, sed, grep, diff, patch, tar, gzip, gunzip, zcat,
base64, md5sum, sha1sum, sha256sum, cat, head, tail, wc, cut,
sort, uniq, tee, seq, ls, find, cp, mv, rm, mkdir, touch,
basename, dirname
sed and grep use Go's regexp package, so patterns are RE2: backreferences
and lookahead are unavailable.
An unrecognized command prints command not found and exits 127; it is never
looked up on the host. A script that ends there reports OutcomeNotFound, while
one that handles it and picks its own status reports that status. An unsupported
flag is an error rather than a silent no-op.
tar and patch treat archive and patch member names as untrusted: a leading
/ is dropped so the member lands under the extraction root, and a name that
climbs out with .. is rejected.
pip is not available, and the built-in site-packages starts empty. Only
pure-Python code and the compiled-in extension modules (including zlib) are
importable.
Third-party pure-Python packages come from the host, as a directory it prepares
and the sandbox imports from. sbsh never installs anything: preparation happens
outside, with whatever tool the host already uses.
$ python -m pip install --target ./vendor/python attrs
$ sbsh --mount ./vendor/python:/lib/python/site-packages:ro -c "python -c 'import attrs; print(attrs.__version__)'"/lib/python/site-packages is a convention, not an option: a directory mounted
there is treated as an ordinary site directory, and one that is not mounted costs
nothing. There is no flag to go with it, and the Go API uses the same convention
— python.LibraryRoot. .pth files in a staged tree are processed the way
Python processes them anywhere else. The root is scoped to the sandbox it is
mounted into.
Dependencies are the host's business: pip install --target stages the whole
dependency tree, and whatever it stages is what the sandbox can import; sbsh
resolves nothing. There is no validation pass either — whether an import works is
settled at import time by what the sandbox filesystem shows then, so a tree that
a deny pattern hides, or one the host rewrites afterwards, simply fails to
import.
Pure-Python only. Compiled extension modules were built for a host ABI and
this interpreter cannot load them, so a package that ships one installs cleanly
with pip and then fails to import here, as an ordinary Python import error.
How it reads depends on how the package reaches for its extension; a compiled
file sitting next to the module that could not be imported is the thing to look
for.
Per Exec call:
- Timeout — 30 seconds by default (
WithTimeout). A script stopped by it reports128 + SIGKILL(137), and one stopped because the caller cancelled the context reports128 + SIGINT(130), the way a real shell reports a killed process. Both arrive asResult.ExitCodewith a nil error, and asOutcomeTimedOut/OutcomeCanceled, so the caller can tell "stopped" from "failed on its own" without treating a limit it asked for as a sandbox failure. A command that notices the interruption and returns a status of its own does not change that: being stopped is why the run ended, so it is what the outcome says. - Output — stdout and stderr are captured in memory and capped at 4 MiB each
by default (
WithOutputLimit, or--output-limiton the CLI). Past the cap, output is discarded andResult.Truncatedis set; the REPL says so on stderr. Truncation is never reported as success. - Serialization —
Execholds a lock, so concurrent callers queue. A sandbox is one shell session, not a pool. A command running inside the sandbox does not queue behind it and does not re-enter it: see Nested execution. - Nesting depth — executions may nest 8 levels deep. A request past that
reports
OutcomeDeniedrather than running. - No bytecode cache — every
pythoninvocation compiles the modules it imports from source. The standard library is mounted read-only and no.pycis shipped, because a committed one could never be accepted: its recorded source timestamp cannot match a tree that is copied into memory at startup.
Not implemented:
- CPU and memory limits. A busy loop is stopped by the timeout, not by a resource cap. Memory is bounded only by the Wasm runtime's own limits.
- Process substitution (
<(...)), rejected at parse time because it needs host FIFOs. Command substitution, pipelines, and redirections all work. pipand package installation. The host stages a package tree and the sandbox imports from it — see Python.- Packages with compiled extensions, for the same reason.
- Persistent history across REPL sessions.
- Ctrl-C at an interactive terminal. Raw mode clears
ISIG, so the keystroke never becomes a signal; the line editor reports it as end of input and the REPL exits, the same as Ctrl-D. A Ctrl-C pressed while a script runs is read once the script finishes, and ends the session then. SendingSIGINTfrom another terminal interrupts the script as described above, and piped input and-care unaffected. - Windows support is untested;
HostFSrelies onos.Rootsemantics.
What sbsh is for. Running a script an agent wrote, without reading it first. The script reaches the directories you mounted and the network destinations you allowed, and nothing else on the host.
Trust boundary. Sandboxed code is untrusted; the host process is trusted. There are exactly two crossings, both closed by default:
- Filesystem — only through
vfs.HostFS, which delegates toos.Root. Symlinks inside a mount cannot escape it,..is normalized away, and read-only mounts returnEROFS. A path no mount covers does not exist as far as the sandbox is concerned. Deny patterns are matched against resolved paths, so a link inside the mount is not a way past them. - Network — only through the
http.Clientthatnetpolicybuilds, with the allow-list check described above.
Everything else is absent rather than filtered: no host process is ever created, and the Wasm module receives a filesystem and no other capability.
- Preventing exfiltration of what the sandbox may legitimately read. If a mount exposes a file and the allow list permits a destination, sbsh will not stop one from reaching the other. Scope the mounts and the allow list.
- Where an allowed name points. A host entry grants whatever that name
resolves to.
--allow-net example.comreaches127.0.0.1or169.254.169.254if that is whatexample.comanswers, so a name whose DNS you do not control is a possible route to a service on your machine or to the cloud metadata endpoint. List names you trust to resolve where you expect, and use an IP or CIDR entry when the destination has to be pinned. - Resource exhaustion. The timeout bounds how long a script runs; the output
cap bounds how much output is kept. Memory is not bounded — the Wasm runtime is
created without a memory limit, and builtins such as
sorthold their input on the host process's heap — so a large enough input takes the host process down with the sandbox. - Wasm-runtime and Go-stdlib vulnerabilities. sbsh is a defense-in-depth layer inside one process, not an OS- or VM-level sandbox. For genuinely hostile input, rather than merely untrusted generated code, run sbsh inside a real isolation boundary as well.
- Read-only versus write-only deny rules. Deny patterns close both directions; the read/write distinction belongs to mounts.
- Aliases a path cannot show. Deny patterns select names, and symlinks are resolved before matching, but a hard link or a bind mount in the mount source reaches the same file under a name that looks unrelated. A file that must not be read belongs outside the mount.
MIT. See LICENSE.
The shell interpreter is github.com/mrtc0/sh,
a fork of mvdan.cc/sh v3.13.1 licensed under
BSD-3-Clause; see its LICENSE.
An sbsh binary embeds CPython, so distributing it distributes CPython. That is
done under the PSF License, which asks
that the License Agreement and PSF's copyright notice travel with the copy. The
same applies to what python.wasm is linked against: wasi-libc (Apache-2.0 WITH
LLVM-exception, Apache-2.0 and MIT), LLVM's compiler-rt, and zlib.
Those texts live in pywasm/dist/licenses/, collected by
pywasm/Dockerfile from the same sources the artifact is built
from, and recorded in pywasm/dist/PROVENANCE by version.
Release archives carry them under licenses/.
THIRD_PARTY_LICENSES collects everything in one file — the
Go modules linked into cmd/sbsh and the embedded Python runtime — and is
regenerated by
scripts/gen-third-party-licenses.sh.