Skip to content

noperator/membrane

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

74 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.

Features

  • Network egress filtering: Hostnames are whitelisted, DNS-resolved at startup, and refreshed continuously.
     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

Install

go install github.com/noperator/membrane/cmd/membrane@latest
ln -s $(go env GOPATH)/bin/membrane $(go env GOPATH)/bin/mb  # optional short alias

On first run, membrane will clone the repo to ~/.membrane/src/, build the Docker image, and write a default config to ~/.membrane/config.yaml. Subsequent runs check for updates automatically.

cd /your/workspace
membrane

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, --arg stringArray        extra docker run argument (repeatable)
  -c, --cidr stringArray       allowed IP or CIDR range (repeatable)
  -n, --hostname stringArray   allowed hostname (repeatable)
  -i, --ignore stringArray     ignore pattern (repeatable)
  -r, --readonly stringArray   readonly pattern (repeatable)
      --resolver string        DNS resolver (overrides config file)

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
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 image

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

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

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 image, and ~/.membrane/. Workspace .membrane.yaml files are not affected. You can also reset individual components:

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

Trace execution

By default, membrane records a 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 hostnames, 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.
# `resolver` is the DNS resolver used by both the firewall and the
# container. Defaults to 8.8.8.8 if not set.
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. Use
# this for things like .git (so the agent can read history but not
# rewrite it) or credential files that should be visible but not
# writable.
readonly:
  - config/

# `hostnames` lists hostnames the agent is allowed to reach. The firewall
# resolves these to IPs at startup and refreshes continuously. Anything
# not listed is dropped.
hostnames:
  - internal.mycompany.com

# `cidrs` lists IP addresses or CIDR ranges added directly to the firewall
# allowlist without DNS resolution. Bare IPs are treated as /32.
cidrs:
  - 192.168.2.1
  - 192.168.3.0/24

# `args` lists raw arguments appended to the `docker run` command that
# launches the agent container. Use this to pass environment variables,
# additional mounts, port mappings, or anything else accepted by
# `docker run`. Environment variables are expanded ($VAR, ${VAR})
# anywhere in a value, including mid-string (e.g. MY_PATH=$HOME/mydir).
# Shell command substitution ($(...)) is not supported β€” set secrets in
# your shell environment first and reference them here. Values are
# passed directly to `docker run` without shell interpretation. Do not
# add shell-style quoting around values β€” quotes are treated as literal
# characters. Each flag and its argument must be separate list items.
args:
  - -e
  - MY_API_KEY=abc123
  - -e
  - "MY_VALUE=my value with space"
  - -v
  - $HOME/.aws:/home/agent/.aws:ro
  - -e
  - AWS_PROFILE=myprofile

See config-default.yaml for the full default config including the built-in hostname allowlist.

Troubleshooting

This project is an experimental work in progress. There are likely more opportunities to lock this down further. A few common issues:

  • Network not working: The firewall resolves hostnames to IPs at startup. If a CDN rotates IPs, the connection may fail until the next refresh (every 60s). Check /var/log/firewall-updater.log inside the container for refresh status.

  • Docker-in-Docker not working: Sysbox must be installed on the host. membrane detects it automatically; if it's not present, Docker-in-Docker is silently disabled.

Back matter

See also

To-do

  • support Docker-in-Docker on macOS
  • support Docker checkpoint
  • whitelist HTTPS paths/endpoints
Completed
  • pass via config via CLI (in addition to file)
  • whitelist IPs
  • set custom DNS resolver
  • mount agent home dir as ~/.membrane/home on host
  • monitor agent with eBPF
  • specify hostnames at runtime
  • git-aware read-only mounts
  • refresh firewall after init
  • quiet down logging a bit
  • make ignore/readonly configurable
  • allow reading from host stdin (to be used in pipeline)

About

Selectively permeable boundary for AI agents

Resources

Stars

Watchers

Forks

Contributors