Amjad Jibon
Published on

memsh: a virtual bash shell in Go

Authors
  • avatar
    Name
    Amjad Hossain
    Twitter

Most shells are wrappers around the operating system. They fork processes, open real files, and inherit the host environment. That is exactly what you want when you are writing a deploy script or grepping through logs. It is exactly what you do not want when you need a sandboxed scripting environment, an LLM tool that cannot touch production, or a testable shell runtime embedded in a Go program.

memsh takes a different approach: the shell runs against an afero.MemMapFs in-memory filesystem. The real OS filesystem is never touched. External OS commands are blocked by default. Every command — ls, grep, jq, sqlite3, git, and now go — is a native Go plugin registered at startup.

What it is

memsh                              # interactive REPL
echo "ls /tmp" | memsh             # pipe a script
memsh ./etl-pipeline.sh            # run a script file
memsh serve                        # expose over HTTP with session-scoped virtual FS
memsh mcp                          # MCP server for LLM tool use

The shell parser and interpreter come from mvdan.cc/sh/v3. Pipes, redirects, &&, subshells, and aliases all work. What does not work is reaching outside the sandbox — which is the point.

The plugin model

Every command is a Go struct implementing a two-method interface:

type Plugin interface {
    Name() string
    Run(ctx context.Context, args []string) error
}

Inside Run, two context values give you everything you need:

hc := interp.HandlerCtx(ctx)  // pipe-aware stdin / stdout / stderr
sc := plugins.ShellCtx(ctx)   // virtual FS, cwd, env, ResolvePath

You never touch os.Stdout. You never open a real file. The virtual filesystem and the pipe graph are handed to you by the runtime.

Adding a command is three steps: create a file in pkg/shell/plugins/native/, implement the interface, add it to defaultNativePlugins() in defaults.go.

Running Go inside the shell

The most recent addition is a go command backed by MVM, a pure-Go interpreter that compiles Go source to bytecode and runs it on a stack-based VM. It emulates the real go toolchain interface:

# go run — execute a source file from the virtual filesystem
echo 'package main' > /main.go
echo 'func main() { fmt.Println("hello") }' >> /main.go
go run /main.go
# hello

# go test — find and run Test* functions
echo 'package main' > /math_test.go
echo 'import "testing"' >> /math_test.go
echo 'func TestAdd(t *testing.T) { if 1+1 != 2 { t.Error("broken") } }' >> /math_test.go
go test / -v
# === RUN   TestAdd
# --- PASS: TestAdd
# ok

# go fmt — gofmt source in place
echo 'package main' > /ugly.go
echo 'func main(){fmt.Println("hi")}' >> /ugly.go
go fmt /ugly.go
cat /ugly.go
# package main
#
# func main() { fmt.Println("hi") }

# stdin pipe — stdlib auto-imported, no package declaration needed
echo 'fmt.Println(strings.ToUpper("hello"))' | go
# HELLO

go test rewrites *testing.T to a built-in shim at code-generation time (MVM does not fully support testing.RunTests). The shim covers t.Error, t.Errorf, t.Fatal, t.Fatalf, t.Log, t.Run, and t.Skip.

A five-stage pipeline in twelve lines

Here is what memsh looks like embedded in a Go program. The script runs entirely in memory — no temp files, no cleanup, no disk I/O.

var out bytes.Buffer
sh, _ := shell.New(shell.WithStdIO(nil, &out, &out))
defer sh.Close()

sh.Run(ctx, `
    cat > /sales.json << 'EOF'
    [{"product":"widget","amount":120},{"product":"gadget","amount":340}]
    EOF

    # filter with jq
    jq -r '.[] | select(.amount > 200) | .product' /sales.json

    # aggregate in SQLite
    sqlite3 /db << 'SQL'
    CREATE TABLE s (product TEXT, amount INT);
    INSERT INTO s VALUES ('widget',120),('gadget',340);
    SELECT product, amount FROM s ORDER BY amount DESC;
    SQL

    # validate with Go
    echo 'package main' > /check.go
    echo 'func main() { fmt.Println(120+340) }' >> /check.go
    go run /check.go
`)

fmt.Print(out.String())
// gadget
// gadget|340
// widget|120
// 460

Each stage — jq, sqlite3, go run — is a native Go plugin. No subprocesses. No temp files. One sh.Run call.

Built-in commands

The shell ships with sixty-plus commands out of the box:

CategoryCommands
Filesystemls, cd, cp, mv, rm, mkdir, find, stat, du, df
Textcat, grep, sed, awk, sort, uniq, cut, wc, tr, head, tail
Datajq, yq, base64, sqlite3, bc, expr
Scriptinggo, lua, goja (JavaScript ES2020+)
Archivetar, gzip, zip
Networkcurl, ssh
VCSgit (pure Go, via go-git)
Checksumsmd5sum, sha256sum, and four others
Schedulingcrontab

WASM plugins extend this further: Python 3.12, Ruby 3.2, and PHP 8.2 runtimes install from a single command.

go run . plugin install python
python3 -c 'print("hello from wasm")'

The HTTP server and MCP

memsh serve exposes the shell over HTTP. Each session gets its own afero.Fs that persists across requests — send X-Session-ID: <id> on POST /run and the virtual filesystem survives between calls.

memsh mcp starts a Model Context Protocol server. Any MCP-compatible LLM client gets a memsh tool: it can run bash-like commands in a sandboxed environment without ever touching the real filesystem.

{
  "mcpServers": {
    "memsh": { "command": "memsh", "args": ["mcp"] }
  }
}

What it is good for

  • Sandboxed script execution — run scripts from user input, CI pipelines, or LLM output without exposing the host
  • Embedded shell in Go programs — one shell.New() call, no subprocess, full bash-like syntax
  • Test fixtures — seed the virtual FS, run a script, assert output; no temp dirs, no cleanup
  • ETL pipelines — ingest JSON or YAML, transform with jq/awk/yq, sink to sqlite3
  • LLM tool — give a model a shell that cannot escape the sandbox

The code is at github.com/amjadjibon/memsh.