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 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 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:-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"
systemd-run --user --scope --unit=agent-A -- $PI
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/<pid>/ns shows.cred->user_ns`` str(args->filename)
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):
root@krish-dev:~# cat baseline-ns.txt
...
lrwxrwxrwx 1 root root 0 Oct 27 02:57 mnt -> mnt:[4026531841]
lrwxrwxrwx 1 root root 0 Oct 27 02:57 net -> net:[4026531840]
lrwxrwxrwx 1 root root 0 Oct 27 02:57 pid -> pid:[4026531836]
lrwxrwxrwx 1 root root 0 Oct 27 02:57 user -> user:[4026531837]
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.
#!/usr/bin/env bash
echo "== identity"
echo "user=$(id -un) cwd=$PWD"
echo "== pid as seen from this /proc (a small number = own PID namespace)"
grep NSpid /proc/self/status
echo "== namespace ids (compare with baseline-ns.txt)"
for ns in pid mnt net user uts ipc cgroup; do
printf '%-7s %s\n' "$ns" "$(readlink /proc/self/ns/$ns)"
done
echo "== processes visible"
ls -d /proc/[0-9]* 2>/dev/null | wc -l
echo "== write tests (error text says who blocked it)"
for f in "$PWD/.nsprobe" "$HOME/.nsprobe" "$HOME/.bashrc" "${TMPDIR:-/tmp}/.nsprobe"; do
if err=$(touch "$f" 2>&1); then
echo "OK $f"
else
echo "FAIL $f (${err##*: })"
fi
done
rm -f "$PWD/.nsprobe" "$HOME/.nsprobe" "${TMPDIR:-/tmp}/.nsprobe" 2>/dev/null
echo "== read test (fake credential)"
head -c 40 "$HOME/.fake-creds/token" 2>&1; echo
echo "== direct network (bypassing any proxy)"
curl -s --noproxy '*' -m 5 -o /dev/null -w 'HTTP %{http_code}\n' https://example.com \
|| 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 |
NSpid tells you
Both the probe and the sleep check later rely on one line in /proc/<pid>/status:
/proc you're reading.17.
Then I did four runs.
pi was launched from ~/dev/demo with nothing around it.
The tracer:
PID PPID LVL PIDNS MNTNS NETNS USERNS FILE
20960 20921 0 4026531836 4026531841 4026531840 4026531837 /usr/bin/ls
20962 20961 0 4026531836 4026531841 4026531840 4026531837 /usr/bin/which
20963 20961 0 4026531836 4026531841 4026531840 4026531837 /usr/local/bin/nsprobe
The probe:
== identity
user=root cwd=/root/dev/demo
== pid as seen from this /proc (a small number = own PID namespace)
NSpid: 20991
== namespace ids (compare with baseline-ns.txt)
pid pid:[4026531836]
mnt mnt:[4026531841]
net net:[4026531840]
user user:[4026531837]
uts uts:[4026531838]
ipc ipc:[4026531839]
cgroup cgroup:[4026531835]
== processes visible
104
== write tests (error text says who blocked it)
OK /root/dev/demo/.nsprobe
OK /root/.nsprobe
OK /root/.bashrc
OK /tmp/.nsprobe
== read test (fake credential)
FAKE-TOKEN-do-not-use
== direct network (bypassing any proxy)
HTTP 200
20991. One value means one PID namespace: the host's.~/.bashrc included.which nsprobe; nsprobe --help 2>&1 | head -50. --help ran the whole probe: the writes, the token read and the outbound call. pi only noticed afterwards: nsprobe --help doesn't print help — it just runs the probe itself."which nsprobe; nsprobe 2>&1 | head -50. In run B, it ran the probe straight away and only then thought
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.
Same launch directory, with pi inside the sandbox.
The tracer, trimmed:
PID PPID LVL PIDNS MNTNS NETNS USERNS FILE
21271 21270 1 4026532336 4026532333 4026531840 4026531837 /usr/local/bin/nsprobe
21272 21271 1 4026532336 4026532333 4026531840 4026531837 /usr/bin/id
21274 21271 1 4026532336 4026532333 4026531840 4026531837 /usr/bin/readlink
...
21284 21283 1 4026532336 4026532333 4026531840 4026531837 /usr/bin/touch
21293 21271 1 4026532336 4026532333 4026531840 4026531837 /usr/bin/curl
21601 21600 1 4026532336 4026532333 4026531840 4026531837 /usr/bin/sleep
== identity
user=root cwd=/root/dev/demo
== pid as seen from this /proc (a small number = own PID namespace)
NSpid: 17
== namespace ids (compare with baseline-ns.txt)
pid pid:[4026532336]
mnt mnt:[4026532333]
net net:[4026531840]
user user:[4026531837]
uts uts:[4026532334]
ipc ipc:[4026532335]
cgroup cgroup:[4026531835]
== processes visible
6
== write tests (error text says who blocked it)
OK /root/dev/demo/.nsprobe
FAIL /root/.nsprobe (Read-only file system)
FAIL /root/.bashrc (Read-only file system)
OK /tmp/.nsprobe
== read test (fake credential)
FAKE-TOKEN-do-not-use
== direct network (bypassing any proxy)
HTTP 200
Then I asked pi to run sleep 60 and looked it up from the host:
root@krish-dev:~# grep NSpid /proc/$(pgrep -n sleep)/status
NSpid: 21601 42
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. id, grep, seven readlink s, ls, wc, four touch es, rm, head and curl. Every one inherited the same namespaces. A sandbox applies to a process and Read-only file system, even as root./ (needs CAP_SYS_ADMIN) or override permissions (needs CAP_DAC_OVERRIDE)./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./tmp write worked, but never reached the host.--tmpfs /tmp mounts a new, empty, in-memory filesystem on top. The file existed only inside the sandbox and disappeared when it exited.--unshare-pid gives the sandbox its own PID numbering, so the host's 21601 is 42 inside. --proc /proc mounts a new procfs, and a procfs lists only pid_namespaces(7)). Without --proc, the recursive /etc/resolv.conf was readable through the read-only root and pointed at the systemd-resolved stub on 127.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.
~
The policy didn't change. Only the directory I started from did.
== identity
user=root cwd=/root
== pid as seen from this /proc (a small number = own PID namespace)
NSpid: 44
== namespace ids (compare with baseline-ns.txt)
pid pid:[4026532334]
mnt mnt:[4026532330]
net net:[4026531840]
user user:[4026531837]
uts uts:[4026532331]
ipc ipc:[4026532333]
cgroup cgroup:[4026531835]
== processes visible
5
== write tests (error text says who blocked it)
OK /root/.nsprobe
OK /root/.nsprobe
OK /root/.bashrc
OK /tmp/.nsprobe
== read test (fake credential)
FAKE-TOKEN-do-not-use
== direct network (bypassing any proxy)
HTTP 200
44 and 5 processes visible. Network and user namespaces unchanged again.OK /root/.nsprobe appears twice.$PWD and $HOME were both /root, so the probe's first two write tests hit the same file.--bind "$PWD" "$PWD" now meant --bind /root /root. That writable mount sat on top of the read-only root at ~/.fake-creds, and ~/.ssh if it existed. The ~/.pi bind on top became redundant.
The policy didn't change, but its input did. A sandbox that binds $PWD turns 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.
NET=off adds --unshare-net, so the sandbox gets a new, empty network namespace. I asked pi to run the probe again:
Error: Connection error.
Error: Connection error.
Error: Connection error.
Error: Connection error.
Error: Retry failed after 3 attempts: Connection error.
api.deepseek.com but its
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.
| 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 whenNET=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.
Three things never came up in the probe, but they're worth knowing:
systemd-run scope I used for tracing is the natural place to add limits like -p MemoryMax=.
The sandbox did exactly what I told it to. The problem was what I didn't know I was telling it:
--ro-bind and --bind gave me real control here, but the launch directory quietly decides how much is writable.
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.