~ $ cat blog/isolate-claude-code.md
sandboxing your coding agent with bwrap
coding agents have become essential tools that we use every day, not just devs, but many other professionals too. everyone is playing with their favorite coding agent these days, either fixing bugs, adding features to their project, or vibing the next life-changing app.
reality check
these programs can do a lot of things, pretty much anything we can do from our terminal, since this is where they live and operate from.
i’ve been playing with them for a while, and over and over i’ve seen how, even though they are supposed to be limited to the project root path, they end up reading paths way above their paygrade.
my risk model
personally, i want to have control over what my coding agent has access to and what can it read and modify.
if i’m working on my project /home/user/projects/project1, i don’t want my agent to end up reading
everything on /home/user/projects because it is freaking out for not finding the solution to the current issue.
at times it will decide that maybe by scanning the whole directory above, it might find something related that could help on
solving the issue (it has happened to me before).
i don’t want my coding agent to be able to read anything that is not related to the current project it’s working on.
my solution
good security is implemented in layers, so the trick i’m about to explain is only one part of my containing approach when working with coding agents.
i know some of the coding agents have a permissions layer that will ask if it’s allowed to read xyz path or file, but let’s be honest, this is a quite annoying feature, and if i’m using a tool to speed up my workflow, the last thing i want is to have to push enter everytime the tool has to run a command.
my approach is to use bubblewrap, which is what claude code uses internally to control what the agent can read and write. i just want to be more tight on the permissions. by default claude code allows writes only in the working project’s directory, and reads on the full computer. we are going to change that.
full command first, breakdown later.
note: this solution is working for linux systems with namespaces enabled.
wrapped_claude() { local node_bin node_dir claude_bin project node_bin=$(realpath "$(command -v node)") node_dir=$(dirname "$(dirname "$node_bin")") claude_bin=$(realpath "$(command -v claude)") project=$PWD
local lib64_args=() if [[ -e /lib64 ]]; then lib64_args=(--ro-bind /lib64 /lib64) fi
bwrap \ --die-with-parent \ --ro-bind /bin /bin \ --ro-bind /usr /usr \ --ro-bind /lib /lib \ "${lib64_args[@]}" \ --ro-bind /etc /etc \ --ro-bind "$node_dir" "$node_dir" \ --ro-bind "$HOME/.cargo" "$HOME/.cargo" \ --ro-bind "$HOME/.rustup" "$HOME/.rustup" \ --bind "$HOME/.claude" "$HOME/.claude" \ --bind "$HOME/.claude.json" "$HOME/.claude.json" \ --bind "$project" "$project" \ --unshare-pid \ --unshare-ipc \ --unshare-uts \ --unshare-cgroup \ --dev /dev --proc /proc --tmpfs /tmp \ --share-net \ --clearenv \ --setenv HOME "$HOME" \ --setenv TERM "$TERM" \ --setenv PATH "$node_dir/bin:$HOME/.cargo/bin:/usr/bin:/bin" \ --chdir "$project" \ "$claude_bin" --allow-dangerously-skip-permissions "$@"}dissecting the command
--die-with-parent: in case the terminal dies, kill the process. avoids leaving orphans.--ro-bind /bin /bin: binds/binread-only. needed for the agent to run basic tasks--ro-bind /usr /usr: binds/usrread-only.--ro-bind /lib /lib: binds/libread-only.--ro-bind /etc /etc: binds/etcread-only.
makes node available to the agent:
--ro-bind "$node_dir" "$node_dir": read-only bind to give the agent access tonode
makes rust toolchain available to the agent:
--ro-bind "$HOME/.cargo" "$HOME/.cargo": holds package registry, crate sources, etc--ro-bind "$HOME/.rustup" "$HOME/.rustup": holds compiler binaries and toolchains
binds necessary files for claude to work:
--bind "$HOME/.claude" "$HOME/.claude": binds.claudedirectory in write mode to allow Claude modify its config--bind "$HOME/.claude.json" "$HOME/.claude.json": Claude credentials live here, so write access is needed
shares the project directory:
--bind "$project" "$project": here we bind our working project directory, write access obviously
now we get to the interesting parts:
--unshare-pid: this isolates the new process into its own PID namespace. it makes claude run on PID 1, and it will not see any current PID currently running on the machine. it doesn’t just prevent the process from seeing other processes, but the arguments to them too. if another running process had some keys passed as arguments, those would be visible to this process, this avoids exactly that.--unshare-ipc: less critical, but interesting to add. inter-process communication allows processes to share memory segments and message queues that any other process in the same IPC namespace can read. by unsharing ipc we totally isolate this process from the rest of our machine.--unshare-uts: isolated UTS namespace, which means the process in the sandbox can modify the hostname without any effect on the full host. just for hygiene.--unshare-cgroup: isolated cgroup namespace. not critical. creates a clean cgroup root namespace for the sandbox, so it doesn’t have any access to the host’s structure.--dev /dev --proc /proc --tmpfs /tmp: sincebwrapcreates an empty root/, we need to enable those to have a minimal functioning system.--share-net: the agent will need access to the network to have access to the models. we could have a more hardened approach with proxies here, but the threat model is just against the tool wandering everywhere in your machine. once we can control what it can read, the fact that it communicates through the network is less critical.--clearenv: clears all existing envs on the session before handing it out to the process.--setenv HOME "$HOME": we need to re-set basic env $HOME for many processes to work correctly.--setenv TERM "$TERM": and $TERM so things look nice again.--setenv PATH "$node_dir/bin:$HOME/.cargo/bin:/usr/bin:/bin": we re-map $PATH, since no extra.zshrcor others are run in the sandbox.--chdir "$project": and we move into our project."$claude_bin" --allow-dangerously-skip-permissions "$@": and we run the agent! since we are doing all the sandboxing,--allow-dangerously-skip-permissionsis a nice to have when you want things to work unattended.$@allows us to forward any other argument into claude.
ack’s
no commits
i’m not letting my coding agent to commit for me, my workflow is to discuss/ask for some changes, then let it implement, and then i check the changes with my editor or directly in my git tool, so i keep control over my commits.
if you want to give it power to commit you should ro-bind :
~/.gitconfig~/.ssh/known_hosts~/.ssh/config~/.ssh/your_git_key
no auto-update
since node directory is binded as read-only, and claude is installed with npm, the auto-update fails.
i prefer it this way, and i can update whenever i want with
npm update -g @anthropic-ai/claude-codeif you prefer to allow auto-update, simply bind node directory as writeable.
other agents
i also use opencode, and the setup for it is exactly the same. if you also like playing with different tools, you can port the same setup. just update the config paths, on opencode those are:
~/.opencode: read-only. writeable if you want to allow auto-update~/.config/opencode: read-write~/.local/share/opencode: read-write
obviously the $opencode_bin must be the appropiate, and any extra hardcoded parameter that you might want to have.
conclusion
as usual, a whitelist and deny-by-default is always safer than a blacklist and allow-by-default.
claude simply recommends you to deny the most sensitive directories from your machine, but if we have learned something is that a default becomes the default for most people, and all the not tech savvy users of these coding agents will potentially be exposing many secrets to these LLM APIs.
this workaround solves exactly this. be safe out there.
x