memsh
June 19, 2026 · View on GitHub
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,-jrcetc. 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
Plugininterface - Interactive REPL — tab completion, command history,
.memshrcstartup 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
| Command | Description |
|---|---|
cat | Concatenate and print files |
cd | Change working directory |
chmod | Change file permissions (-R recursive) |
cp | Copy files or directories (-r) |
cut | Extract fields (-f) or characters (-c) |
date | Print current date and time |
df | Report filesystem disk space usage |
diff | Compare two files line by line (-u unified) |
du | Estimate file space usage |
echo | Print arguments (-n, -e) |
env | Print or set environment variables |
find | Search virtual filesystem (-name, -type, -maxdepth) |
grep | Search file contents (-i, -n, -v, -r, -c, -l, -w, -o) |
head | Print first N lines (-n) or bytes (-c) |
ln | Create hard or symbolic links (-s, -f) |
ls | List directory contents (-l, -a, -R) |
mkdir | Create directories (-p, -v, -m) |
mv | Move or rename files |
printf | Format and print data |
pwd | Print working directory |
read | Read a line from stdin into a variable |
rm | Remove files or directories (-f, -r, -v) |
rmdir | Remove empty directories |
sed | Stream editor (substitution) |
seq | Print a sequence of numbers |
sleep | Delay for a specified amount of time |
sort | Sort lines (-r, -u, -n) |
stat | Show file status |
tail | Print last N lines (-n) or bytes (-c) |
tee | Read stdin; write to stdout and files (-a) |
timeout | Run a command with a time limit |
touch | Create or update file timestamps |
tr | Translate or delete characters (-d, -s, -c) |
uniq | Filter adjacent duplicate lines (-c, -d, -u) |
wc | Count lines, words, and bytes (-l, -w, -c) |
which | Locate a command |
xargs | Build and execute command lines from stdin |
yes | Repeatedly output a string |
awk | Pattern scanning and processing |
base64 | Encode or decode base64 (-d) |
bc, expr | Arbitrary precision calculator / expression evaluator |
column | Columnate output |
crontab | Schedule commands with cron expressions |
curl | Transfer data from URLs |
envsubst | Substitute environment variables in strings |
go | Go tool — go run, go test, go fmt against the virtual filesystem |
goja | Execute JavaScript (ES2020+) code |
git | Pure-Go git implementation |
gzip, gunzip | Compress/decompress gzip files |
hexdump, xxd | Hex dump of files |
jq | Command-line JSON processor |
less, more | Scrollable pager (web terminal) |
ln | Create links |
lua | Execute Lua 5.1 code |
man, help | Show help for commands |
md5sum, sha256sum, … | File checksum (md5, sha1, sha224, sha256, sha384, sha512) |
mktemp | Create a temporary file or directory |
sqlite3 | SQLite database shell |
ssh | Connect to a remote memsh server |
tar | Archive files |
tree | Display directory contents as an ASCII tree (-L, -a, -d, -f) |
tput, stty | Terminal control stubs |
yq | Command-line YAML/JSON processor |
zip, unzip | Compress/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 testrewrites*testing.Tto a built-in shim;t.Error,t.Errorf,t.Fatal,t.Fatalf,t.Log,t.Run,t.Skipall work.- MVM is alpha (v0.3.0); some stdlib packages are partially supported. Unsupported calls surface as interpreter errors.
- stdin mode and
go runuse 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
WithPlugin(p)orWithPluginBytes(name, wasm)options- Native Go plugins registered in
defaultNativePlugins() - Embedded WASM from
defaultPluginsmap (currently empty) /memsh/plugins/*.wasmin the virtual FS~/.memsh/plugins/*.wasmon the real OS filesystem
Options
| Option | Description |
|---|---|
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.
| Endpoint | Description |
|---|---|
GET / | Web terminal UI |
POST /run | {"script":"..."} → {"output":"...","cwd":"...","error":"..."} |
GET /sessions | List 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}/snapshot | Export session filesystem as JSON |
POST /session/{id}/snapshot | Import 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:
| Transport | Command | Use case |
|---|---|---|
| stdio (default) | memsh mcp | Claude Desktop, Claude Code CLI |
| HTTP (MCP 2025-03-26+) | memsh mcp --transport http --addr :8080 | Programmatic / multi-session |
| SSE (legacy) | memsh mcp --transport sse --addr :8080 | Legacy 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/quitare 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:
- Commit and push any uncommitted changes (prepares for release)
- Clean the
dist/directory (removes old build artifacts) - Clean build artifacts (bin/ and *.wasm files)
- Create and push a git tag
- Build binaries for all platforms (Linux, macOS, Windows × AMD64, ARM64)
- Create a GitHub Release with all binaries
- 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.