Skip to content

noperator/membrane

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

91 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

logo
Selectively permeable boundary for AI agents.

Description

Membrane is a lightweight, agent-agnostic, cross-platform sandbox that gives you real-time visibility into everything that your agent does.

The most important property of a secure sandbox is that you can clearly understand what it's doing. As it gets bigger and more complex, it introduces more potential failure points. Membrane is deliberately minimal. It covers the core features you'd expect from an agent sandbox (namely, network and filesystem isolation) and omits everything else. At the time of this writing, membrane's codebase is 50X smaller than OpenShell, or about 2% the size. Simplicity is a feature.

$ find OpenShell/ -name '*.rs' -exec cat {} \; | wc -c
 2833412
$ find membrane/ -name '*.go' -exec cat {} \; | wc -c
   55689
$ echo 2833412 / 55689 | bc -l
50.88

Features

  • Network egress filtering: Allowed hosts, ports, HTTP methods, and HTTP paths are enforced via firewall/proxy.
     Most tools don't filter the network at all, or require manual iptables rules that are easy to misconfigure.
  • Filesystem isolation: Sensitive files can be masked and made invisible to the agent, or mounted read-only.
     Most tools offer no granular filesystem controls on top of bind mounts.
  • Observability: eBPF traces all agent filesystem, network, and process activity at the kernel level.
     Most tools offer no runtime visibility into what the agent is actually doing.
  • Nested containers: Docker-in-Docker via unprivileged Sysbox containers.
     Most tools require --privileged (unsafe) or a separate hypervisor.
  • Agent-agnostic: Wraps any process or command, not coupled to a specific agent.
     Most tools are tightly coupled to a specific agent (Claude Code, Codex, etc.).
  • Cross-platform: Linux and macOS via Docker; strong enforcement on both platforms.
     Most tools rely on OS-specific primitives: Landlock and bubblewrap (Linux), Seatbelt and Apple Containers (macOS).
  • Lightweight: Container-based, near-zero startup overhead on top of Docker.
     Most tools that offer kernel-level isolation do so at the expense of requiring a full hypervisor.
  • Unix-native: Use with shell pipelines, GNU parallel, or script it however you want.
     Most tools target IDE-attached environments that are awkward to drive programmatically.

Getting started

Prerequisites

Membrane has been tested on macOS and Ubuntu Linux. On macOS, Homebrew must be installed for the first-run install script to install Colima and Docker CLI (if needed). On Linux, Docker Engine must be installed and running; the first-run install script installs Sysbox on top of an existing Docker installation.

Install

go install github.com/noperator/membrane/cmd/membrane@latest

On first run, membrane checks that all dependencies are present (or otherwise offers to install them). It then clones the repo to ~/.membrane/src/, builds the membrane-agent and membrane-handler Docker images, and writes a default config to ~/.membrane/config.yaml. Subsequent runs check for updates automatically. Initial install takes about 5 minutes.

On macOS, membrane runs inside a dedicated Colima VM with Sysbox installed. If these aren't present, membrane will offer to run scripts/install-macos.sh which installs Colima and Docker CLI via Homebrew, creates a dedicated Colima VM, and installs Sysbox inside the VM and registers it as a Docker runtime. The dedicated Colima profile keeps membrane's containers and images isolated from your existing Docker setup.

On Linux, membrane uses the system Docker daemon directly. If Sysbox isn't installed, membrane will offer to run scripts/install-linux.sh which installs and registers it automatically.

Usage

membrane -h

Usage: membrane [options] [-- command...]

Options:
      --no-trace           disable Tracee eBPF sidecar
      --no-update          skip checking for updates
      --reset[=cid]        remove membrane state and exit (c=containers, i=image, d=directory)
      --trace-log string   path for trace log file (default: ~/.membrane/trace/<id>.jsonl.gz)

Config:
  -a, --allow stringArray      allow rule: hostname, IP, CIDR, or URL (repeatable)
      --arg stringArray        extra docker run argument (repeatable)
      --dns-resolver string    DNS resolver (overrides config file)
  -i, --ignore stringArray     ignore pattern (repeatable)
  -r, --readonly stringArray   readonly pattern (repeatable)

Optionally pass a specific command to be executed, using -- to separate membrane options from the command to run inside the container.

# Drop into a shell
cd /your/workspace
membrane

# Run a specific command
membrane -- claude -p "just say hello"
membrane -- bash -c "echo hello"

Non-interactive mode

When stdin is not a terminal, membrane automatically skips PTY allocation and wires stdin/stdout/stderr directly. This lets you pipe input, capture output, and use membrane in scripts or tools like GNU parallel.

# Pipe input
echo 'Today is my birthday, but no one noticed.' |
    membrane -- claude -p 'Tell me something nice.'

Happy birthday! πŸŽ‚

# Capture output to a file
echo 'target char count: 20' |
    membrane -- claude -p 'Output something that matches the exact target character count and nothing more.' |
    tee /dev/stderr | tr -d '\n' | wc -c

This is twenty chars
      20
Advanced usage

Modify the images

If you want to customize the Dockerfiles, firewall rules, or entrypoints, edit the files in ~/.membrane/src/ and rebuild:

docker build -t membrane-agent ~/.membrane/src/docker/agent/
docker build -t membrane-handler ~/.membrane/src/docker/handler/

If you've made local edits and an update is available, membrane will back up ~/.membrane/src/ to a timestamped directory before pulling.

Reset

membrane --reset will remove running containers, the Docker images, and ~/.membrane/. Workspace .membrane.yaml files are not affected. You can also reset individual components:

membrane --reset=cid   # all
membrane --reset=ci    # containers and images only

Trace execution

By default, membrane records an eBPF trace of everything the agent does. In this example, I just tell Claude to go download the homepage of my blog.

membrane --trace-log=blog.jsonl -- \
    claude --dangerously-skip-permissions \
    -p 'Download the homepage of my blog noperator.dev and save it to blog.html.'

Done β€” saved the homepage to `/workspace/blog.html` (16,927 bytes).

Now we can look at the eBPF trace with jq and grep to show the full story of what Claude did in the container:

𝄒 jq -rs '
  sort_by(.timestamp) |
  (map(select(.processName == "gosu")) | last | .timestamp) as $t |
  .[] | select(.timestamp > $t) |
  if .eventName == "sched_process_exec" then
    "exec  \(.processName): \(.args[] | select(.name == "argv") | .value | join(" "))"
  elif .eventName == "net_packet_dns" and ((.args[] | select(.name == "metadata") | .value.direction) == 2) then
    "dns   \(.processName) β†’ \(.args[] | select(.name == "proto_dns") | .value.questions[0] | "\(.name) \(.type)")"
  elif .eventName == "security_file_open" then
    "file  \(.processName): \(.args[] | select(.name == "flags") | .value) \(.args[] | select(.name == "pathname") | .value)"
  elif .eventName == "security_socket_connect" then
    "conn  \(.processName): \(.args[] | select(.name == "remote_addr") | .value | "\(.sa_family) \(.sin_addr // .sin6_addr // .sun_path):\(.sin_port // .sin6_port // "")")"
  else empty end
' blog.jsonl | grep -vE '^file.* /(usr|dev|etc|proc|sys|run|home|workspace/\.git|tmp/claude)|^conn.* /var|^\s|^$| git(-remote-http)?:'

eBPF can be pretty noisy and there's a lot to analyze here, but the main gist of what we see is:

  • the agent is given the initial prompt
  • it explores the filesystem to see which tools are available
  • finally it uses curl to save the blog homepage to disk
Full trace
exec  claude: /usr/bin/env node /usr/bin/claude --dangerously-skip-permissions -p Download the homepage of my blog noperator.dev and save it to blog.html.
exec  node: node /usr/bin/claude --dangerously-skip-permissions -p Download the homepage of my blog noperator.dev and save it to blog.html.
conn  node: AF_INET 8.8.8.8:53
dns   node β†’ api.anthropic.com A
conn  node: AF_INET 8.8.8.8:53
dns   node β†’ api.anthropic.com A
exec  sh: /bin/sh -c which npm
exec  sh: /bin/sh -c which bun
exec  sh: /bin/sh -c which yarn
exec  sh: /bin/sh -c which deno
exec  sh: /bin/sh -c which pnpm
conn  claude: AF_INET 160.79.104.10:443
exec  sh: /bin/sh -c which node
conn  node: AF_INET 8.8.8.8:53
dns   node β†’ api.anthropic.com A
conn  node: AF_INET 8.8.8.8:53
dns   node β†’ api.anthropic.com A
conn  claude: AF_INET 160.79.104.10:443
file  node: 149504 /workspace
conn  claude: AF_INET 160.79.104.10:443
conn  claude: AF_INET 160.79.104.10:443
conn  node: AF_INET 8.8.8.8:53
dns   node β†’ api.anthropic.com A
conn  claude: AF_INET 160.79.104.10:443
exec  sh: /bin/sh -c which git
exec  rg: /usr/lib/node_modules/@anthropic-ai/claude-code/vendor/ripgrep/arm64-linux/rg --version
exec  rg: /usr/lib/node_modules/@anthropic-ai/claude-code/vendor/ripgrep/arm64-linux/rg --files --hidden /workspace
file  rg: 147456 /workspace
file  rg: 147456 /workspace/pkg
file  rg: 147456 /workspace/test
file  rg: 147456 /workspace/pkg/membrane
file  rg: 147456 /workspace/img
file  rg: 147456 /workspace/cmd
file  rg: 147456 /workspace/cmd/membrane
exec  sh: /bin/sh -c ps aux | grep -E "code|cursor|windsurf|idea|pycharm|webstorm|phpstorm|rubymine|clion|goland|rider|datagrip|dataspell|aqua|gateway|fleet|android-studio" | grep -v grep
exec  grep: grep -E code|cursor|windsurf|idea|pycharm|webstorm|phpstorm|rubymine|clion|goland|rider|datagrip|dataspell|aqua|gateway|fleet|android-studio
exec  ps: ps aux
exec  grep: grep -v grep
dns   git-remote-http β†’ github.com A
dns   git-remote-http β†’ github.com AAAA
exec  which: /bin/sh /usr/bin/which /usr/lib/node_modules/@anthropic-ai/claude-code/vendor/ripgrep/arm64-linux/rg
exec  which: /bin/sh /usr/bin/which bwrap
exec  which: /bin/sh /usr/bin/which socat
exec  sh: /bin/sh -c npm root -g
exec  npm: /usr/bin/env node /usr/bin/npm root -g
exec  node: node /usr/bin/npm root -g
exec  uname: uname -sr
exec  sh: /bin/sh -c which zsh
exec  sh: /bin/sh -c which bash
exec  bash: /bin/bash -c -l SNAPSHOT_FILE=/home/agent/.claude/shell-snapshots/snapshot-bash-1772485556640-5hbuui.sh
exec  locale-check: /usr/bin/locale-check C.UTF-8
exec  cut: cut -d  -f3
exec  grep: grep -vE ^_[^_]
exec  head: head -n 1000
exec  awk: awk {print "set -o " $1}
exec  head: head -n 1000
exec  grep: grep on
exec  sed: sed s/^alias //g
exec  sed: sed s/^/alias -- /
exec  head: head -n 1000
exec  bash: /bin/bash -c source /home/agent/.claude/shell-snapshots/snapshot-bash-1772485556640-5hbuui.sh && shopt -u extglob 2>/dev/null || true && eval 'curl -sL -o /workspace/blog.html https://noperator.dev' \< /dev/null && pwd -P >| /tmp/claude-cca8-cwd
exec  curl: curl -sL -o /workspace/blog.html https://noperator.dev
conn  curl: AF_INET 8.8.8.8:53
dns   curl β†’ noperator.dev A
dns   curl β†’ noperator.dev AAAA
conn  curl: AF_INET 104.21.91.7:443
conn  curl: AF_INET 172.67.163.253:443
conn  curl: AF_INET6 2606:4700:3037::ac43:a3fd:443
conn  curl: AF_INET6 2606:4700:3035::6815:5b07:443
conn  curl: AF_INET 104.21.91.7:443
conn  node: AF_INET 8.8.8.8:53
dns   node β†’ api.anthropic.com A
conn  claude: AF_INET 160.79.104.10:443
file  node: 131072 /workspace/blog.html
exec  bash: /bin/bash -c source /home/agent/.claude/shell-snapshots/snapshot-bash-1772485556640-5hbuui.sh && shopt -u extglob 2>/dev/null || true && eval 'wc -c /workspace/blog.html && head -5 /workspace/blog.html' \< /dev/null && pwd -P >| /tmp/claude-5f6c-cwd
exec  wc: wc -c /workspace/blog.html
file  wc: 131072 /workspace/blog.html
exec  head: head -5 /workspace/blog.html
file  head: 131072 /workspace/blog.html
file  node: 131072 /workspace/blog.html
conn  node: AF_INET 8.8.8.8:53
dns   node β†’ api.anthropic.com A
conn  node: AF_INET 8.8.8.8:53
dns   node β†’ http-intake.logs.us5.datadoghq.com A
conn  claude: AF_INET 160.79.104.10:443
conn  claude: AF_INET 34.149.66.137:443

Configure

Configuration is YAML and works at two levels:

  • Global (~/.membrane/config.yaml): Applies to every workspace. Written from the default template on first run. Edit this to set your baseline allow list, ignore patterns, and readonly patterns.
  • Workspace (.membrane.yaml in your project root): Applies to the current workspace only. Lists in the workspace config are appended to the global config, not replaced.
# `dns_resolver` is the upstream DNS resolver. Defaults to 1.1.1.1.
dns_resolver: 1.1.1.1

# `ignore` lists patterns matched against filenames or relative paths.
# Matching files and directories are shadowed with an empty placeholder
# inside the container; the agent can see they exist but cannot read
# their contents.
ignore:
  - secrets/
  - "*.pem"

# `readonly` lists patterns mounted into the container as read-only.
readonly:
  - config/

# `allow` lists what the agent is allowed to reach. Each entry is
# auto-detected from its value:
#
#   hostname:  DNS-resolved, any port, no L7 filtering
#   IP:        added directly to firewall as /32
#   CIDR:      added directly to firewall
#   URL:       DNS-resolved, port from scheme, L7 filtering via mitmproxy
#
# Object form supports additional constraints. For hostnames, `ports`
# restricts to specific ports. For URLs, `http` enables L7 enforcement:
# methods and paths are OR'd within a rule, rules are OR'd within `http`.
# Paths without a leading `/` are relative to the URL's path prefix.
# If no `http` key is present, all methods and paths are allowed.
allow:
  # plain hostname: any port, any method, passthrough
  - internal.mycompany.com

  # hostname with port restriction
  - dest: registry.mycompany.com
    ports: [443]

  # IP and CIDR: bypass DNS, added directly to firewall
  - 192.168.2.1
  - 192.168.3.0/24

  # URL: allow all methods/paths under /v1/ (no http key)
  - https://api.anthropic.com/v1/

  # URL: restrict to specific methods and paths
  - dest: https://api.anthropic.com/v1/
    http:
      - methods: [POST]
        paths:
          - messages    # relative: /v1/messages
          - /v1/models  # absolute path

  # URL: multiple rules (OR semantics)
  - dest: https://api.example.com/posts/
    http:
      - methods: [GET]   # GET anything under /posts/
      - methods: [POST]  # POST only to /posts/new
        paths: [new]

# `args` lists raw arguments appended to the `docker run` command.
# Environment variables are expanded ($VAR, ${VAR}). Each flag and
# its argument must be separate items.
args:
  - -e
  - MY_API_KEY=abc123
  - -v
  - $HOME/.aws:/home/agent/.aws:ro
  - -e
  - AWS_PROFILE=myprofile

See config-default.yaml for the full default allow list.

Back matter

See also

To-do

  • support Docker checkpoint
  • support wildcard hostnames
  • detect HTTP(S) via bytes vs ports
  • optimize startup/teardown time
  • move tracee from dedicated sidecar into handler
Completed
  • support Docker-in-Docker on macOS
  • whitelist HTTPS paths/endpoints with L7 method/path filtering
  • pass config via CLI (in addition to file)
  • whitelist IPs and CIDRs
  • set custom DNS resolver
  • mount agent home dir as ~/.membrane/home on host
  • monitor agent with eBPF
  • specify allow rules at runtime
  • git-aware read-only mounts
  • refresh firewall on DNS resolution (dns-proxy)
  • quiet down logging a bit
  • make ignore/readonly configurable
  • allow reading from host stdin (to be used in pipeline)
  • auto-install prerequisites on first run