
🧪 Three Decisions, Three Places
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.
- Can it write? Decided by the mount the path lives on.
- Can it read? Decided by the file’s owner and mode bits, the same as outside the sandbox.
- Can it talk? Decided by the network namespace the process is in.
My policy controlled the first one well. The other two leaked in every run where the agent could work at all.
The Setup
| Piece | What I used |
|---|---|
| OS | Ubuntu 24.04 LTS on a throwaway cloud VM, everything run as root |
| Agent | pi v1.4.0. A deliberately minimal agent with no built-in sandbox or permission prompts |
| Model | DeepSeek (deepseek-v4-flash) |
| Sandbox | 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
bwrapfails for normal users until you add a small AppArmor profile for it in/etc/apparmor.d/bwrap.
The Policy, Flag by Flag
This is the whole sandbox, saved as sandbox-agent:
1args=(
2 --ro-bind / / # whole filesystem visible, read-only
3 --dev /dev # minimal device nodes
4 --proc /proc # fresh /proc: only sandboxed processes listed
5 --tmpfs /tmp # private, empty /tmp
6 --bind "$PWD" "$PWD" # the launch directory is writable
7 --bind "$HOME/.pi" "$HOME/.pi" # the agent's own state
8 --unshare-pid # own PID namespace
9 --unshare-ipc --unshare-uts # own IPC objects and hostname
10 --die-with-parent # tear down if the launcher dies
11 --chdir "$PWD"
12)
13# NET=off also unshares the network namespace
14[[ "${NET:-on}" == "off" ]] && args+=(--unshare-net)
15
16exec bwrap "${args[@]}" "$@"
How bwrap turns these flags into a sandbox
It helps to know what bwrap actually does with that list, because every result later comes back to it:
- It creates new namespaces. One for mounts, plus one for each
--unshare-*flag. Anything not unshared, like the network here, stays shared with the host. - It builds a new root from nothing. The new mount namespace starts with an empty tmpfs root that the host never sees.
- It applies the filesystem flags in order.
--ro-bind / /goes in first, as the base layer. Every later flag mounts on top of it. The bwrap man page says it directly: filesystem options are applied in the order they are given. That’s why--bind "$PWD"can be writable inside a read-only root. - It switches into that root with
pivot_root, so the host’s real mounts are no longer reachable. - It drops capabilities. From the man page: “By default no caps are left in the sandboxed process.” Even as root.
- It runs the command.
Keep steps 3 and 5 in mind. They explain most of what follows.
The Observers
Giving the agent its own cgroup
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.
1PI="pi --provider deepseek --model deepseek-v4-flash"
2
3# Run A: no sandbox
4systemd-run --user --scope --unit=agent-A -- $PI
5# Runs B-D: the same, wrapped in the sandbox
6systemd-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.
The tracer: agent-exec.bt
1BEGIN {
2 printf("%-7s %-7s %-3s %-11s %-11s %-11s %-11s %s\n",
3 "PID", "PPID", "LVL", "PIDNS", "MNTNS", "NETNS", "USERNS", "FILE");
4}
5
6tracepoint:sched:sched_process_exec
7/ cgroup == cgroupid("CGROUP_PATH") /
8{
9 $t = curtask;
10 printf("%-7d %-7d %-3d %-11u %-11u %-11u %-11u %s\n",
11 pid, // PID as the host sees it
12 $t->real_parent->tgid, // parent PID, host view
13 $t->thread_pid->level, // PID ns depth: 0 = host, 1 = nested
14 $t->nsproxy->pid_ns_for_children->ns.inum, // PID ns its children get
15 $t->nsproxy->mnt_ns->ns.inum, // mount namespace
16 $t->nsproxy->net_ns->ns.inum, // network namespace
17 $t->cred->user_ns->ns.inum, // user namespace
18 str(args->filename)); // binary that was exec'd
19}
What each part does:
BEGIN { ... }runs once when bpftrace starts and prints the column header.tracepoint:sched:sched_process_execis a fixed hook in the kernel that fires every time a process successfully replaces itself with a new program (execve). One event per program started, which is exactly the “what did the agent run” question./ cgroup == cgroupid("CGROUP_PATH") /is a filter that runs inside the kernel.cgroupis the cgroup ID of the process that triggered the event, andcgroupid()turns the scope’s path under/sys/fs/cgroupinto that ID when the script starts. Events from everything else on the VM are dropped before they reach userspace.CGROUP_PATHis a placeholder I replaced withsedfor each run.$t = curtaskis a pointer to the kernel’s own record of the process (struct task_struct). bpftrace can walk its fields because the kernel ships its type layouts as BTF in/sys/kernel/btf/vmlinux.pidandreal_parent->tgidare the process and its parent, as the host numbers them.thread_pid->levelis how deep the process’s PID namespace is nested: 0 for the host, 1 for one sandbox in. This is theLVLcolumn.nsproxy->..._ns->ns.inumreads the namespace IDs.nsproxyis the per-process bundle of namespace pointers, andns.inumis the same inode numberls -l /proc/<pid>/nsshows.cred->user_nscomes from the process’s credentials instead ofnsproxy, because user namespaces are tied to who you are, not just to what you see.str(args->filename)is the path of the program being started.
The namespace IDs only mean something next to a baseline. This is my own shell’s, outside everything (I covered how these IDs work in Isolation in Containers):
1root@krish-dev:~# cat baseline-ns.txt
2...
3lrwxrwxrwx 1 root root 0 Oct 27 02:57 mnt -> mnt:[4026531841]
4lrwxrwxrwx 1 root root 0 Oct 27 02:57 net -> net:[4026531840]
5lrwxrwxrwx 1 root root 0 Oct 27 02:57 pid -> pid:[4026531836]
6lrwxrwxrwx 1 root root 0 Oct 27 02:57 user -> user:[4026531837]
The probe: nsprobe
nsprobe reports what the process running it can see and do: its identity, PID and namespaces, how many processes it can see, whether it can write in four places, read the fake token and make a direct HTTPS call. pi runs it, so it inherits whatever sandbox pi is in.
1#!/usr/bin/env bash
2# nsprobe: report what the calling process can see and do.
3# Run it through the agent so it inherits the agent's sandbox (or lack of one).
4
5echo "== identity"
6echo "user=$(id -un) cwd=$PWD"
7
8echo "== pid as seen from this /proc (a small number = own PID namespace)"
9grep NSpid /proc/self/status
10
11echo "== namespace ids (compare with baseline-ns.txt)"
12for ns in pid mnt net user uts ipc cgroup; do
13 printf '%-7s %s\n' "$ns" "$(readlink /proc/self/ns/$ns)"
14done
15
16echo "== processes visible"
17ls -d /proc/[0-9]* 2>/dev/null | wc -l
18
19echo "== write tests (error text says who blocked it)"
20# "Read-only file system" -> sandbox mount rules
21# "Permission denied" -> ordinary Unix permissions
22for f in "$PWD/.nsprobe" "$HOME/.nsprobe" "$HOME/.bashrc" "${TMPDIR:-/tmp}/.nsprobe"; do
23 if err=$(touch "$f" 2>&1); then
24 echo "OK $f"
25 else
26 echo "FAIL $f (${err##*: })"
27 fi
28done
29# touch on ~/.bashrc only bumps its timestamp; remove the probe files we created
30rm -f "$PWD/.nsprobe" "$HOME/.nsprobe" "${TMPDIR:-/tmp}/.nsprobe" 2>/dev/null
31
32echo "== read test (fake credential)"
33head -c 40 "$HOME/.fake-creds/token" 2>&1; echo
34
35echo "== direct network (bypassing any proxy)"
36curl -s --noproxy '*' -m 5 -o /dev/null -w 'HTTP %{http_code}\n' https://example.com \
37 || echo "no direct route"
The write tests print the error text, because the error tells you who said no:
| Error | Who blocked it |
|---|---|
Read-only file system (EROFS) |
The mount. That’s the sandbox |
Permission denied (EACCES) |
The file’s owner and mode bits. That’s ordinary Unix permissions |
What NSpid tells you
Both the probe and the sleep check later rely on one line in /proc/<pid>/status:
- PID namespaces nest. A process in a sandbox belongs to the sandbox’s PID namespace and to every namespace above it, and it has a separate PID in each one.
NSpidlists all of them, from the outermost to the innermost. The outermost is the namespace of whoever mounted the/procyou’re reading.- One value means one namespace. On the host, an ordinary process shows just its host PID.
- Two values means one level of nesting: the host’s number first, then the number inside the sandbox, which counts from 1.
- Inside the sandbox you only see the inner number. The sandbox’s
/procwas mounted from inside, so its outermost level is the sandbox itself. That’s why the probe prints a small number like17. - It’s the same depth as the tracer’s
LVL.thread_pid->levelis the kernel’s count of how many levels sit above the innermost one.
Then I did four runs.
Run A - No Sandbox
pi was launched from ~/dev/demo with nothing around it.
The tracer:
1PID PPID LVL PIDNS MNTNS NETNS USERNS FILE
220960 20921 0 4026531836 4026531841 4026531840 4026531837 /usr/bin/ls
320962 20961 0 4026531836 4026531841 4026531840 4026531837 /usr/bin/which
420963 20961 0 4026531836 4026531841 4026531840 4026531837 /usr/local/bin/nsprobe
The probe:
1== identity
2user=root cwd=/root/dev/demo
3== pid as seen from this /proc (a small number = own PID namespace)
4NSpid: 20991
5== namespace ids (compare with baseline-ns.txt)
6pid pid:[4026531836]
7mnt mnt:[4026531841]
8net net:[4026531840]
9user user:[4026531837]
10uts uts:[4026531838]
11ipc ipc:[4026531839]
12cgroup cgroup:[4026531835]
13== processes visible
14104
15== write tests (error text says who blocked it)
16OK /root/dev/demo/.nsprobe
17OK /root/.nsprobe
18OK /root/.bashrc
19OK /tmp/.nsprobe
20== read test (fake credential)
21FAKE-TOKEN-do-not-use
22
23== direct network (bypassing any proxy)
24HTTP 200
What I observed in run A
- Same namespaces as my shell.
LVLwas 0, and every namespace ID matched the baseline. NSpidhad a single value,20991. One value means one PID namespace: the host’s.- 104 processes visible. That’s every process on the VM.
- Every write succeeded,
~/.bashrcincluded. With no mount namespace, the paths are the real files on disk. - The token was readable and HTTPS got out, as expected with nothing in the way.
- The agent ran the probe before it knew what it was. Its first move was
which nsprobe; nsprobe --help 2>&1 | head -50.nsprobeignores arguments, so--helpran the whole probe: the writes, the token read and the outbound call. pi only noticed afterwards: "nsprobe --helpdoesn’t print help — it just runs the probe itself." - The same pattern showed up in every run. In run C, pi’s reasoning said “Let me check if nsprobe exists”, and the check was
which nsprobe; nsprobe 2>&1 | head -50. In run B, it ran the probe straight away and only then thought “this is a suspicious command name. Let’s look at it.”
Without a sandbox, the agent is simply you. And an agent’s “look before you leap” is usually a leap: its exploring is real execution, so whatever protection you add has to hold for that too, not just for the command you asked for.
Run B - Sandboxed, from the Project Directory
Same launch directory, with pi inside the sandbox.
The tracer, trimmed:
1PID PPID LVL PIDNS MNTNS NETNS USERNS FILE
221271 21270 1 4026532336 4026532333 4026531840 4026531837 /usr/local/bin/nsprobe
321272 21271 1 4026532336 4026532333 4026531840 4026531837 /usr/bin/id
421274 21271 1 4026532336 4026532333 4026531840 4026531837 /usr/bin/readlink
5...
621284 21283 1 4026532336 4026532333 4026531840 4026531837 /usr/bin/touch
721293 21271 1 4026532336 4026532333 4026531840 4026531837 /usr/bin/curl
821601 21600 1 4026532336 4026532333 4026531840 4026531837 /usr/bin/sleep
The probe:
1== identity
2user=root cwd=/root/dev/demo
3== pid as seen from this /proc (a small number = own PID namespace)
4NSpid: 17
5== namespace ids (compare with baseline-ns.txt)
6pid pid:[4026532336]
7mnt mnt:[4026532333]
8net net:[4026531840]
9user user:[4026531837]
10uts uts:[4026532334]
11ipc ipc:[4026532335]
12cgroup cgroup:[4026531835]
13== processes visible
146
15== write tests (error text says who blocked it)
16OK /root/dev/demo/.nsprobe
17FAIL /root/.nsprobe (Read-only file system)
18FAIL /root/.bashrc (Read-only file system)
19OK /tmp/.nsprobe
20== read test (fake credential)
21FAKE-TOKEN-do-not-use
22
23== direct network (bypassing any proxy)
24HTTP 200
Then I asked pi to run sleep 60 and looked it up from the host:
1root@krish-dev:~# grep NSpid /proc/$(pgrep -n sleep)/status
2NSpid: 21601 42
What I observed in run B
- New PID, mount, UTS and IPC namespaces. Network, user and cgroup unchanged. The PID and mount IDs (
4026532336,4026532333) differ from the host’s, and so do UTS and IPC. The network (4026531840), user and cgroup IDs are identical to my shell’s. Only the flags I passed created namespaces, and I never passed--unshare-net. - One command, 18 programs, all inside. That single
nsprobestartedid,grep, sevenreadlinks,ls,wc, fourtouches,rm,headandcurl. Every one inherited the same namespaces. A sandbox applies to a process and everything it ever spawns. ~/.bashrcfailed withRead-only file system, even as root.--ro-bind / /doesn’t change any file. It puts a read-only flag on the mount, and before any write the kernel checks whether the mount is writable. That check ignores your UID and capabilities. On top of that, bwrap dropped every capability before starting pi, so root inside can’t remount/(needsCAP_SYS_ADMIN) or override permissions (needsCAP_DAC_OVERRIDE).- The project write worked, and landed on the real disk.
--bind "$PWD"came after--ro-bind / /, so it was mounted on top at/root/dev/demo. When the kernel resolves a path and reaches a mount point, it uses the topmost mount there. A bind mount isn’t a copy. It’s the same directory, visible in a second place. - The
/tmpwrite worked, but never reached the host.--tmpfs /tmpmounts a new, empty, in-memory filesystem on top. The file existed only inside the sandbox and disappeared when it exited. - The fake token was still readable. Reading never asks the mount for write access, so the read-only flag never comes into play. A read is checked only against the file’s owner and mode bits, the same as outside the sandbox. The token is owned by root, and the process was UID 0. A read-only bind controls what can change, not what can be seen.
- 6 processes, and one process with two PIDs. That takes two flags.
--unshare-pidgives the sandbox its own PID numbering, so the host’s21601is42inside.--proc /procmounts a new procfs, and a procfs lists only “processes visible in the PID namespace of the process that performed the mount” (pid_namespaces(7)). Without--proc, the recursive--ro-bind / /would have brought in the host’s/proc, and the probe would have counted 104 again. - HTTPS still got out. pi stayed in the host’s network namespace, with the same interfaces and routes. DNS worked because
/etc/resolv.confwas readable through the read-only root and pointed at the systemd-resolved stub on127.0.0.53, which sits on the shared loopback.
The sandbox controlled writes well, because writes are a mount question and mounts were what I configured. It didn’t control reads, because reads aren’t a mount question. And it didn’t control the network, because I never unshared it. As far as secrets and the network go, run B is run A.
Run C - Same Sandbox, Launched from ~
The policy didn’t change. Only the directory I started from did.
The probe:
1== identity
2user=root cwd=/root
3== pid as seen from this /proc (a small number = own PID namespace)
4NSpid: 44
5== namespace ids (compare with baseline-ns.txt)
6pid pid:[4026532334]
7mnt mnt:[4026532330]
8net net:[4026531840]
9user user:[4026531837]
10uts uts:[4026532331]
11ipc ipc:[4026532333]
12cgroup cgroup:[4026531835]
13== processes visible
145
15== write tests (error text says who blocked it)
16OK /root/.nsprobe
17OK /root/.nsprobe
18OK /root/.bashrc
19OK /tmp/.nsprobe
20== read test (fake credential)
21FAKE-TOKEN-do-not-use
22
23== direct network (bypassing any proxy)
24HTTP 200
What I observed in run C
- Still sandboxed. New PID and mount namespaces (fresh IDs, since it’s a new sandbox),
NSpidof44and 5 processes visible. Network and user namespaces unchanged again. OK /root/.nsprobeappears twice.$PWDand$HOMEwere both/root, so the probe’s first two write tests hit the same file.- Every write succeeded,
~/.bashrcincluded.--bind "$PWD" "$PWD"now meant--bind /root /root. That writable mount sat on top of the read-only root at/root, so everything under it resolved to a writable mount: dotfiles,~/.fake-creds, and~/.sshif it existed. The~/.pibind on top became redundant. - A writable
~/.bashrcis a way out of the sandbox. Whatever the agent appends runs the next time you open a shell, outside the sandbox, as you, with every capability bwrap took away. The probe only touched the file. A prompt-injected command wouldn’t stop there. - The token and network behaved exactly as in run B.
The policy didn’t change, but its input did. A sandbox that binds$PWDturns the launch directory into part of the policy. That’s the real explanation behind the “different behavior in/vs~vs~/dev” posts: launch this script from/and the whole disk is writable.
Run D - Cutting the Network
NET=off adds --unshare-net, so the sandbox gets a new, empty network namespace. I asked pi to run the probe again:
1Error: Connection error.
2Error: Connection error.
3Error: Connection error.
4Error: Connection error.
5Error: Retry failed after 3 attempts: Connection error.
What I observed in run D
- pi never ran a single command. The tracer log for this run had only its header.
- The new network namespace had only a loopback interface. No route out. The resolver at
127.0.0.53lives on the host’s loopback, not the new one, so most likely even the DNS lookup for the API failed. - The agent and its commands share one network namespace. A namespace belongs to a process, and children inherit it. Wrapping the whole agent puts its API calls inside the same boundary as its commands. There’s no flag that says “pi may reach
api.deepseek.combut itscurlmay not”. - This is why per-command sandboxing exists. Claude Code sandboxes each shell command instead of the whole agent. On Linux, each command runs in a network namespace with no route out, and its traffic goes through a proxy on the host that checks domains against an allowlist.
Cutting the network worked perfectly, and that was the problem. When you wrap the whole agent, you can’t cut its commands off from the internet without cutting the agent off from its model.
What Each Flag Actually Decided
| Question | Decided by | Flag in my policy | What I saw |
|---|---|---|---|
| Can it write here? | The mount at that path: read-only or not | --ro-bind / /, then --bind on top |
EROFS outside the binds, even as root |
| Where does a write land? | Bind (host disk) or tmpfs (memory) | --bind "$PWD", --tmpfs /tmp |
Project writes hit the real disk; /tmp writes vanished |
| How much is writable? | The value of $PWD |
--bind "$PWD" "$PWD" |
From ~, the whole home, .bashrc included |
| Can it read this? | File owner and mode bits | None. A read-only bind doesn’t apply to reads | Fake token read in every run |
| Can it see other processes? | PID namespace and which /proc is mounted |
--unshare-pid + --proc /proc |
6 processes instead of 104; PID 42 vs 21601 |
| Can it reach the network? | Network namespace | --unshare-net, only when NET=off |
Open in A–C; in D, the agent itself went dark |
| Can root override any of it? | Capabilities | bwrap drops all of them by default | No remount, no permission override |
Note on running as root: I ran everything as root on the VM, so bwrap didn’t need a user namespace and the user namespace ID never changed. As a normal user, bwrap creates one to get the privileges it needs to build the sandbox, which is what the AppArmor change is for. The token result would be the same: you’d own the token, so you could read it. Treat these as lab numbers, not a production setup.
What this policy doesn’t do at all
Three things never came up in the probe, but they’re worth knowing:
- No syscall filter. There’s no seccomp profile, so every system call is available. The sandbox shares the host kernel, and a kernel bug is a way out.
- No resource limits. bwrap doesn’t touch cgroups. A runaway build or a fork bomb inside the sandbox uses your machine’s CPU and memory. The
systemd-runscope I used for tracing is the natural place to add limits like-p MemoryMax=. - No protection for what’s already writable. Everything in the project directory can be read, changed or deleted. The sandbox protects the rest of the machine, not your work.
What I’ll Change for the Next Run
Three small changes target the three leaks. I haven’t rerun the probe against them yet, so treat this as the plan, not a result:
1# 1. Refuse to make your home (or the whole disk) "the project"
2if [[ "$PWD" == "$HOME" || "$PWD" == "/" ]]; then
3 echo "sandbox-agent: launch from a project directory, not $PWD" >&2
4 exit 1
5fi
6
7# 2. Hide secrets: mount an empty tmpfs over each secret directory that exists.
8# Appended after --ro-bind / /, so it sits on top and wins.
9for d in "$HOME/.ssh" "$HOME/.aws" "$HOME/.fake-creds"; do
10 if [[ -d "$d" ]]; then
11 args+=(--tmpfs "$d")
12 fi
13done
14
15# 3. Network: wrapping the whole agent can't fix this one.
16# It needs per-command sandboxing with a filtering proxy.
The first two fit in the same 20 lines. The third one is why the next post runs the same probe against Claude Code’s per-command sandbox.
Putting It Together
The sandbox did exactly what I told it to. The problem was what I didn’t know I was telling it:
- Writes are decided by mounts.
--ro-bindand--bindgave me real control here, but the launch directory quietly decides how much is writable. - Reads are decided by ownership, exactly as outside the sandbox. A read-only bind exposes everything it brings in.
- The network is decided by a namespace the agent itself needs, so wrapping the whole agent can’t close it.
Namespaces held the boundary in every run. The leaks were all in my policy: a read-only bind over secrets, a launch directory that was too wide, and a network that had to stay open for the agent to work.
A sandbox makes mistakes smaller, not impossible. It still shares your kernel.