Selectively permeable boundary for AI agents.
Membrane is a lightweight, agent-agnostic, cross-platform sandbox that gives you real-time visibility into everything that your agent does.
- 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.
go install github.com/noperator/membrane/cmd/membrane@latest
ln -s $(go env GOPATH)/bin/membrane $(go env GOPATH)/bin/mb # optional short aliasOn 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
membranemembrane -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"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
20Advanced usage
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.
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 onlyBy 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
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.yamlin 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=myprofileSee config-default.yaml for the full default config including the built-in hostname allowlist.
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.loginside 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.
- https://github.com/trailofbits/claude-code-devcontainer
- https://github.com/RchGrav/claudebox
- https://github.com/anthropics/claude-code/tree/main/.devcontainer
- https://www.anthropic.com/engineering/claude-code-sandboxing
- 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)