I Sandboxed My AI Coding Agent with Bubblewrap. It Still Read My Secrets A developer sandboxed a minimal coding agent (pi v1.4.0 running DeepSeek's deepseek-v4-flash) inside a self-built bubblewrap (bwrap) sandbox on Ubuntu 24.04, tracing every spawned process with bpftrace and an in-sandbox probe, and found the agent could still read a planted fake credential at ~/.fake-creds/token. The experiment showed that a read-only bind of the whole filesystem (--ro-bind / /) leaves secrets readable, and that the sandbox's blast radius depends heavily on which directory the agent is launched from. The writeup attributes each outcome to specific bwrap flags and the kernel mechanisms (namespaces, pivot_root, mount ordering) where those decisions are made. Same agent. Same sandbox. Different folder. Different blast radius. Coding agents run real commands on your machine. A README, an issue or a dependency can steer what they run. I kept seeing posts saying agents behave differently depending on where you launch them: / , ~ or ~/dev . I wanted to know why at the kernel level, not just that they do. So I wrapped a minimal coding agent in a sandbox I built myself, traced every process it started, and asked one question about every result: which flag decided this, and where in the kernel does that decision happen? The short version: a bubblewrap sandbox answers three separate questions in three separate places. | Piece | What I used | |---|---| | OS | Ubuntu 24.04 LTS on a throwaway cloud VM, everything run as root | | Agent | pi https://pi.dev v1.4.0. A deliberately minimal agent with no built-in sandbox or permission prompts | | Model | DeepSeek deepseek-v4-flash | | Sandbox | bubblewrap https://github.com/containers/bubblewrap bwrap , the sandboxing tool Flatpak uses | | Observer outside | bpftrace, logging every program the agent starts, with its namespace IDs | | Observer inside | nsprobe , a small script the agent runs to report what it can see, write, read and reach | | Canary | A fake credential at ~/.fake-creds/token , standing in for ~/.ssh or ~/.aws | pi runs everything with your full access. Whatever sandbox it gets is the one you build, which makes it a clean subject. Note on Ubuntu 24.04: AppArmor blocks unprivileged user namespaces by default, so bwrap fails for normal users until you add a small AppArmor profile for it in /etc/apparmor.d/bwrap . This is the whole sandbox, saved as sandbox-agent : args= --ro-bind / / whole filesystem visible, read-only --dev /dev minimal device nodes --proc /proc fresh /proc: only sandboxed processes listed --tmpfs /tmp private, empty /tmp --bind "$PWD" "$PWD" the launch directory is writable --bind "$HOME/.pi" "$HOME/.pi" the agent's own state --unshare-pid own PID namespace --unshare-ipc --unshare-uts own IPC objects and hostname --die-with-parent tear down if the launcher dies --chdir "$PWD" NET=off also unshares the network namespace "${NET:-on}" == "off" && args+= --unshare-net exec bwrap "${args @ }" "$@" It helps to know what bwrap actually does with that list, because every result later comes back to it: --unshare- flag. Anything not unshared, like the network here, stays shared with the host. --ro-bind / / goes in first, as the base layer. Every later flag mounts --bind "$PWD" can be writable inside a read-only root. pivot root , so the host's real mounts are no longer reachable. Keep steps 3 and 5 in mind. They explain most of what follows. To trace only the agent, it needs something that marks its processes apart from everything else on the VM. I launched pi with systemd-run : systemd-run "may be used to create and start a transient .service or .scope unit and run the specified COMMAND in it." With --scope , the command runs in the foreground in a transient scope, and every scope gets its own cgroup. PI="pi --provider deepseek --model deepseek-v4-flash" Run A: no sandbox systemd-run --user --scope --unit=agent-A -- $PI Runs B-D: the same, wrapped in the sandbox systemd-run --user --scope --unit=agent-B -- sandbox-agent $PI bubblewrap doesn't move processes between cgroups, so everything pi and the sandbox spawn stays in that scope's cgroup. That's the handle the tracer filters on. agent-exec.bt BEGIN { printf "%-7s %-7s %-3s %-11s %-11s %-11s %-11s %s\n", "PID", "PPID", "LVL", "PIDNS", "MNTNS", "NETNS", "USERNS", "FILE" ; } tracepoint:sched:sched process exec / cgroup == cgroupid "CGROUP PATH" / { $t = curtask; printf "%-7d %-7d %-3d %-11u %-11u %-11u %-11u %s\n", pid, // PID as the host sees it $t- real parent- tgid, // parent PID, host view $t- thread pid- level, // PID ns depth: 0 = host, 1 = nested $t- nsproxy- pid ns for children- ns.inum, // PID ns its children get $t- nsproxy- mnt ns- ns.inum, // mount namespace $t- nsproxy- net ns- ns.inum, // network namespace $t- cred- user ns- ns.inum, // user namespace str args- filename ; // binary that was exec'd } What each part does: BEGIN { ... } tracepoint:sched:sched process exec execve . One event per program started, which is exactly the "what did the agent run" question. / cgroup == cgroupid "CGROUP PATH" / cgroup is the cgroup ID of the process that triggered the event, and cgroupid turns the scope's path under /sys/fs/cgroup into that ID when the script starts. Events from everything else on the VM are dropped before they reach userspace. CGROUP PATH is a placeholder I replaced with sed for each run. $t = curtask struct task struct . bpftrace can walk its fields because the kernel ships its type layouts as BTF in /sys/kernel/btf/vmlinux . pid real parent- tgid thread pid- level LVL column. nsproxy- ... ns- ns.inum nsproxy is the per-process bundle of namespace pointers, and ns.inum is the same inode number ls -l /proc/