memsh

June 19, 2026 · View on GitHub

Test Go Reference Go Report Card codecov

A virtual bash shell implemented in Go. memsh executes bash-like commands against an in-memory filesystem — the real OS filesystem is never touched, and external OS commands are blocked by default.

Shell parsing and interpretation is handled by mvdan.cc/sh/v3. Every command is a native Go plugin or WASM plugin — there is no reliance on host system binaries.

Features

  • Sandboxed execution — external OS commands are blocked; only registered plugins can run
  • In-memory filesystem — all file operations target afero.MemMapFs; nothing touches your disk
  • Bash-like syntax — pipes, redirects (>, >>), &&, ;, subshells, aliases
  • 60+ built-in commands — file ops, text processing, archiving, networking, scripting, and more
  • Combined short flags-rf, -la, -jrc etc. work on all commands
  • Scripting languages — Go (MVM interpreter), Lua (gopher-lua), JavaScript (goja ES2020+), JSON/YAML (jq/yq), SQLite
  • WASM plugin system — extend with WASI-compiled plugins (Python, Ruby, PHP runtimes)
  • Native Go plugins — register custom commands via a simple Plugin interface
  • Interactive REPL — tab completion, command history, .memshrc startup script
  • HTTP server — expose the shell over HTTP with session-scoped virtual filesystems
  • Network egress policy — control outbound networking with domain/CIDR/port allowlists
  • Library usage — embed memsh in Go programs for safe, sandboxed shell scripting

Installation

Homebrew

brew tap amjadjibon/memsh
brew install memsh

Go Install

go install github.com/amjadjibon/memsh@latest

Pre-built Binaries

Download pre-built binaries from the GitHub Releases page.

Linux (amd64):

curl -Lo memsh.tar.gz https://github.com/amjadjibon/memsh/releases/latest/download/memsh_linux_amd64.tar.gz
tar xzf memsh.tar.gz
sudo mv memsh /usr/local/bin/

macOS (Apple Silicon):

curl -Lo memsh.tar.gz https://github.com/amjadjibon/memsh/releases/latest/download/memsh_darwin_arm64.tar.gz
tar xzf memsh.tar.gz
sudo mv memsh /usr/local/bin/

macOS (Intel):

curl -Lo memsh.tar.gz https://github.com/amjadjibon/memsh/releases/latest/download/memsh_darwin_amd64.tar.gz
tar xzf memsh.tar.gz
sudo mv memsh /usr/local/bin/

Windows (amd64):

Download memsh_windows_amd64.zip, extract, and add memsh.exe to your PATH.

Build from Source

git clone https://github.com/amjadjibon/memsh.git
cd memsh
go build -o memsh .

Verify Installation

memsh --help

Quick Start

# Interactive REPL
memsh

# Run a script
memsh ./path/to/script.sh

# Pipe commands
echo "mkdir /tmp && echo hello > /tmp/f && cat /tmp/f" | memsh

# HTTP server
memsh serve
memsh serve --addr :3000 --session-ttl 1h --cors-origin https://app.example.com

# Network-restricted shell
memsh --net-mode allowlist \
  --net-allow-domain 'httpbin.org' \
  --net-allow-port 443 \
  -c 'curl https://httpbin.org/get'

Usage as a Library

package main

import (
    "bytes"
    "context"
    "fmt"
    "log"

    "github.com/amjadjibon/memsh/pkg/shell"
)

func main() {
    ctx := context.Background()

    var out bytes.Buffer
    sh, err := shell.New(shell.WithStdIO(nil, &out, &out))
    if err != nil {
        log.Fatal(err)
    }
    defer sh.Close()

    err = sh.Run(ctx, `
        mkdir -p /home/user/docs
        echo '{"name":"alice","role":"admin"}' > /home/user/docs/user.json
        jq -r .name /home/user/docs/user.json
    `)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Print(out.String()) // alice
}

Pre-seeding the Virtual Filesystem

fs := afero.NewMemMapFs()
afero.WriteFile(fs, "/config.yaml", []byte("host: localhost\nport: 8080\n"), 0644)

var out bytes.Buffer
sh, _ := shell.New(
    shell.WithFS(fs),
    shell.WithStdIO(nil, &out, &out),
)
sh.Run(ctx, "yq .host /config.yaml") // localhost

Commands

CommandDescription
catConcatenate and print files
cdChange working directory
chmodChange file permissions (-R recursive)
cpCopy files or directories (-r)
cutExtract fields (-f) or characters (-c)
datePrint current date and time
dfReport filesystem disk space usage
diffCompare two files line by line (-u unified)
duEstimate file space usage
echoPrint arguments (-n, -e)
envPrint or set environment variables
findSearch virtual filesystem (-name, -type, -maxdepth)
grepSearch file contents (-i, -n, -v, -r, -c, -l, -w, -o)
headPrint first N lines (-n) or bytes (-c)
lnCreate hard or symbolic links (-s, -f)
lsList directory contents (-l, -a, -R)
mkdirCreate directories (-p, -v, -m)
mvMove or rename files
printfFormat and print data
pwdPrint working directory
readRead a line from stdin into a variable
rmRemove files or directories (-f, -r, -v)
rmdirRemove empty directories
sedStream editor (substitution)
seqPrint a sequence of numbers
sleepDelay for a specified amount of time
sortSort lines (-r, -u, -n)
statShow file status
tailPrint last N lines (-n) or bytes (-c)
teeRead stdin; write to stdout and files (-a)
timeoutRun a command with a time limit
touchCreate or update file timestamps
trTranslate or delete characters (-d, -s, -c)
uniqFilter adjacent duplicate lines (-c, -d, -u)
wcCount lines, words, and bytes (-l, -w, -c)
whichLocate a command
xargsBuild and execute command lines from stdin
yesRepeatedly output a string
awkPattern scanning and processing
base64Encode or decode base64 (-d)
bc, exprArbitrary precision calculator / expression evaluator
columnColumnate output
crontabSchedule commands with cron expressions
curlTransfer data from URLs
envsubstSubstitute environment variables in strings
goGo tool — go run, go test, go fmt against the virtual filesystem
gojaExecute JavaScript (ES2020+) code
gitPure-Go git implementation
gzip, gunzipCompress/decompress gzip files
hexdump, xxdHex dump of files
jqCommand-line JSON processor
less, moreScrollable pager (web terminal)
lnCreate links
luaExecute Lua 5.1 code
man, helpShow help for commands
md5sum, sha256sum, …File checksum (md5, sha1, sha224, sha256, sha384, sha512)
mktempCreate a temporary file or directory
sqlite3SQLite database shell
sshConnect to a remote memsh server
tarArchive files
treeDisplay directory contents as an ASCII tree (-L, -a, -d, -f)
tput, sttyTerminal control stubs
yqCommand-line YAML/JSON processor
zip, unzipCompress/decompress zip files

Plugin System

Native Go Plugins

Implement the Plugin interface from pkg/shell/plugins:

import (
    "github.com/amjadjibon/memsh/pkg/shell/plugins"
    "mvdan.cc/sh/v3/interp"
)

type HelloPlugin struct{}

func (HelloPlugin) Name() string        { return "hello" }
func (HelloPlugin) Description() string { return "greet the user" }
func (HelloPlugin) Usage() string       { return "hello [name]" }

func (HelloPlugin) Run(ctx context.Context, args []string) error {
    hc := interp.HandlerCtx(ctx)  // pipe-aware I/O — always use this
    sc := plugins.ShellCtx(ctx)   // virtual FS, cwd, ResolvePath, SetEnv, …
    fmt.Fprintf(hc.Stdout, "Hello from %s!\n", sc.Cwd)
    return nil
}

Register at shell creation time:

sh, _ := shell.New(shell.WithPlugin(HelloPlugin{}))

Or add to defaultNativePlugins() in pkg/shell/defaults.go to include it in every shell instance.

JSON Processing (jq)

echo '{"name":"alice","scores":[10,20,30]}' | jq .name          # "alice"
echo '{"name":"alice"}' | jq -r .name                           # alice (no quotes)
echo '{"items":[1,2,3]}' | jq '.items | length'                 # 3
jq -n '{generated: true}'                                        # null input
jq -rc .name data.json                                           # combined flags

YAML/JSON Processing (yq)

echo 'name: alice' | yq .name                                    # alice
echo 'name: alice' | yq -j .                                     # JSON output
printf 'items:\n  - a\n  - b\n' | yq '.items[0]'                # a
yq .host /config.yaml                                            # read from virtual FS
yq -jc . data.yaml                                               # compact JSON output

Lua Scripting

lua -e 'print("hello from lua")'
echo 'for i=1,3 do print(i) end' | lua
lua /script.lua

Go Scripting (go)

The go command emulates the Go toolchain against the virtual filesystem, backed by the MVM interpreter. stdlib is auto-imported — no import statements needed for inline expressions.

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

# go run — fibonacci
echo 'package main' > /fib.go
echo 'func fib(n int) int { if n<=1{return n}; return fib(n-1)+fib(n-2) }' >> /fib.go
echo 'func main() { for i:=0;i<=7;i++ { fmt.Println(i,fib(i)) } }' >> /fib.go
go run /fib.go

# go test — runs Test* functions; reports PASS/FAIL per test
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 /                                          # --- PASS: TestAdd / ok
go test ./...                                      # recurse all subdirs
go test / -run TestAdd                             # filter by name regex
go test / -v                                       # verbose (=== RUN lines)

# go fmt — gofmt source files in the virtual FS
echo 'package main' > /ugly.go
echo 'func main(){fmt.Println("hi")}' >> /ugly.go
go fmt /ugly.go
cat /ugly.go                                       # properly formatted

# stdin pipe — auto-imported stdlib, no package/import needed
echo 'fmt.Println(strings.ToUpper("hello"))' | go
echo 'fmt.Println(math.Sqrt(144))' | go

# go version
go version                                         # go version mvm0.3.0

Notes:

  • go test rewrites *testing.T to a built-in shim; t.Error, t.Errorf, t.Fatal, t.Fatalf, t.Log, t.Run, t.Skip all work.
  • MVM is alpha (v0.3.0); some stdlib packages are partially supported. Unsupported calls surface as interpreter errors.
  • stdin mode and go run use stdlib auto-import — fmt, strings, math, etc. work without explicit imports.

JavaScript Scripting

goja -e 'console.log("hello")'
echo 'console.log("test")' | goja
goja /script.js
goja -e 'const arr=[1,2,3]; console.log(arr.map(x=>x*2).join(","))'

WASM Plugins

WASM plugins are compiled with GOOS=wasip1 GOARCH=wasm. The virtual FS is mounted via WASI so file I/O goes directly into afero.MemMapFs.

go run . plugin install python   # Python 3.12.0 (~25 MB)
go run . plugin install ruby     # Ruby 3.2.2 slim (~8 MB)
go run . plugin install php      # PHP 8.2.6 slim (~6 MB)
go run . plugin install /path/to/plugin.wasm  # local file
go run . plugin list             # list installed plugins

Installed runtimes are stored in ~/.memsh/plugins/*.wasm.

Plugin Loading Priority

  1. WithPlugin(p) or WithPluginBytes(name, wasm) options
  2. Native Go plugins registered in defaultNativePlugins()
  3. Embedded WASM from defaultPlugins map (currently empty)
  4. /memsh/plugins/*.wasm in the virtual FS
  5. ~/.memsh/plugins/*.wasm on the real OS filesystem

Options

OptionDescription
WithFS(fs)Set the afero filesystem (default: afero.NewMemMapFs())
WithCwd(path)Set initial working directory
WithEnv(env)Set initial environment variables
WithStdIO(in, out, err)Set standard I/O streams
WithPlugin(p)Register a native plugin
WithBuiltin(name, fn)Register a raw function as a command
WithPluginBytes(name, wasm)Register a WASM plugin from bytes
WithWASMEnabled(bool)Enable/disable WASM runtime (default: true)
WithPluginFilter(names)Allowlist for WASM plugin discovery
WithDisabledPlugins(names...)Exclude specific plugins by name
WithAllowExternalCommands(bool)Allow falling back to real OS executables (default: false)
WithInheritEnv(bool)Inherit parent process environment (default: true; use false in server mode)
WithAliases(map)Pre-seed the alias table
WithNetworkPolicy(policy)Set outbound network policy (off, allowlist, full)
WithNetworkLimits(limits)Set network request/bytes/runtime limits
WithNetworkUsage(usage)Seed cumulative network usage (for restored sessions)

HTTP Server

go run . serve                          # listen on :8080
go run . serve --addr :3000 --cors-origin https://app.example.com
go run . serve --session-ttl 1h --timeout 30s

Sessions are always enabled. Send X-Session-ID: <id> on POST /run to persist the virtual filesystem across requests.

EndpointDescription
GET /Web terminal UI
POST /run{"script":"..."}{"output":"...","cwd":"...","error":"..."}
GET /sessionsList active sessions (cwd, timestamps, runtime/network usage counters)
DELETE /session/{id}Destroy a session
GET /health{"status":"ok","uptime":"...","sessions":N}
POST /complete{"input":"...","cursor":N} → tab completion
GET /session/{id}/snapshotExport session filesystem as JSON
POST /session/{id}/snapshotImport a snapshot (use "new" as id to create)

Networking Policy Flags

These flags work for both local memsh and memsh serve:

--net-mode off|allowlist|full
--net-allow-domain <domain>    # repeatable, supports *.example.com
--net-allow-cidr <cidr>        # repeatable, e.g. 203.0.113.0/24
--net-allow-port <port>        # repeatable, e.g. 443
--net-max-requests <n>         # 0 = unlimited
--net-max-bytes-sent <n>       # 0 = unlimited
--net-max-bytes-recv <n>       # 0 = unlimited
--net-max-runtime <duration>   # 0 = unlimited, e.g. 30s

Examples:

# Block all outbound networking
memsh --net-mode off -c 'curl https://example.com'

# Allow only HTTPS to httpbin.org
memsh --net-mode allowlist \
  --net-allow-domain 'httpbin.org' \
  --net-allow-port 443 \
  -c 'curl https://httpbin.org/get'

If DNS fails (lookup <host>: no such host), that is environment/network resolution, not a policy deny. A policy deny returns explicit errors like network disabled by policy or destination port ... is not allowed.

GET /sessions now includes usage counters:

{
  "id": "abc123",
  "cwd": "/",
  "created_at": "2026-04-17T08:00:00Z",
  "last_use": "2026-04-17T08:01:00Z",
  "runtime_ms": 1420,
  "network_requests": 3,
  "network_bytes_sent": 512,
  "network_bytes_received": 4096,
  "network_runtime_ms": 280
}

LLM Integration

memsh has two modes for connecting LLMs: an MCP server (any MCP-compatible client) and a built-in agent (interactive ReAct loop).

MCP Server (memsh mcp)

The MCP server exposes a single memsh tool that lets any LLM execute bash commands in a sandboxed in-memory filesystem. The real OS is never touched.

Transports:

TransportCommandUse case
stdio (default)memsh mcpClaude Desktop, Claude Code CLI
HTTP (MCP 2025-03-26+)memsh mcp --transport http --addr :8080Programmatic / multi-session
SSE (legacy)memsh mcp --transport sse --addr :8080Legacy MCP clients

Claude Desktop (~/Library/Application Support/Claude/claude_desktop_config.json):

{
  "mcpServers": {
    "memsh": {
      "command": "/usr/local/bin/memsh",
      "args": ["mcp"]
    }
  }
}

Claude Code CLI:

claude mcp add memsh -- memsh mcp

Other MCP clients — start the HTTP transport and point your client at the endpoint:

memsh mcp --transport http --addr :8080
# Connect to: http://localhost:8080/

Tool behaviour:

  • Tool name: memsh, input field: command (string)
  • The virtual filesystem persists across calls within a session — use it as a scratchpad
  • exit / quit are treated as success, not errors
  • Stdin is not available; commands that read stdin receive EOF
  • Returns command output + current working directory (Cwd: /path)
  • Per-call timeout (default 30 s, minimum 5 s): memsh mcp --timeout 1m
  • WASM plugins (Python/Ruby/PHP) disabled by default for fast startup: memsh mcp --wasm

Example tool call result:

/home/user/data
file1.txt  file2.txt

Cwd: /home/user/data

Configuration

~/.memsh/config.toml is loaded at startup (missing file = defaults):

[shell]
wasm = true          # set false to skip all WASM loading (faster startup)

[plugins]
wasm    = ["python"] # allowlist of ~/.memsh/plugins/*.wasm names; empty = load all
disable = ["wc"]     # exclude specific plugins by name (native or WASM)

Configuration files:

  • ~/.memsh/config.toml — shell and plugin configuration
  • ~/.memsh/.memshrc — startup script (sourced at REPL start and first HTTP session)
  • ~/.memsh/history/ — per-session command history
  • ~/.memsh/plugins/ — user-installed WASM plugins

Testing

go test ./...                        # full test suite
go test ./tests -v                   # integration tests verbose
go test ./tests -run TestJq -v       # single suite
go test ./pkg/shell/... -run TestName  # shell package tests

Development

# Build
make build

# Run tests
make test

# Run coverage report
make cover

# Lint
make lint

# Clean build artifacts
make clean

# View all available commands
make help

Creating a Release

The project uses GoReleaser for automated releases and Homebrew cask generation.

# 1. Test the release process (dry-run)
make release-dry-run TAG=v1.0.0

# 2. Create the actual release
make release TAG=v1.0.0

The make release command will:

  1. Commit and push any uncommitted changes (prepares for release)
  2. Clean the dist/ directory (removes old build artifacts)
  3. Clean build artifacts (bin/ and *.wasm files)
  4. Create and push a git tag
  5. Build binaries for all platforms (Linux, macOS, Windows × AMD64, ARM64)
  6. Create a GitHub Release with all binaries
  7. Generate and push the Homebrew cask automatically via goreleaser to homebrew-memsh

After release, users can install via:

brew tap amjadjibon/memsh
brew install memsh

Note: Ensure GITHUB_TOKEN is set for goreleaser to create releases and push to repositories.

Requirements

  • Go 1.26+

License

See LICENSE.