memsh
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
| 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 |
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 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
WithPlugin(p) or WithPluginBytes(name, wasm) options
- Native Go plugins registered in
defaultNativePlugins()
- Embedded WASM from
defaultPlugins map (currently empty)
/memsh/plugins/*.wasm in the virtual FS
~/.memsh/plugins/*.wasm on 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 / 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
Built-in Agent (memsh agent)
memsh agent runs a ReAct loop: the LLM thinks, calls the memsh tool, observes results, and repeats until the task is done. After each response it pauses for your review.
Provider is inferred from the model name:
| Model prefix |
Provider |
API key env var |
gpt-* |
OpenAI |
OPENAI_API_KEY |
claude-* |
Anthropic |
ANTHROPIC_API_KEY |
gemini-* |
Google |
GOOGLE_API_KEY |
grok-* |
xAI |
XAI_API_KEY |
| any |
OpenAI-compatible |
--base-url + --api-key |
# Interactive TUI (human-in-the-loop)
memsh agent --model claude-opus-4-5
memsh agent --model gpt-4o
memsh agent --model gemini-2.0-flash
memsh agent --model grok-3
# Single query, non-interactive
memsh agent --model claude-opus-4-5 \
--query "create a CSV of 10 random users and compute average age with awk"
# With WASM plugins (Python/Ruby/PHP)
memsh agent --model gpt-4o --wasm
# Explicit API key and base URL (any OpenAI-compatible endpoint)
memsh agent --model my-model --api-key sk-xxx --base-url https://my-provider/v1
The agent uses an isolated afero.MemMapFs — nothing written during the session touches your real filesystem.
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
License
See LICENSE.