# I Sandboxed My AI Coding Agent with Bubblewrap. It Still Read My Secrets

> Source: <https://dev.to/chkrishnatej/i-sandboxed-my-ai-coding-agent-with-bubblewrap-it-still-read-my-secrets-3kod>
> Published: 2026-11-02 18:07:09+00:00

*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/<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](https://chkrishnatej.dev/posts/isolation-for-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.

``` bash
#!/usr/bin/env bash
# nsprobe: report what the calling process can see and do.
# Run it through the agent so it inherits the agent's sandbox (or lack of one).

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)"
#   "Read-only file system" -> sandbox mount rules
#   "Permission denied"     -> ordinary Unix permissions
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
# touch on ~/.bashrc only bumps its timestamp; remove the probe files we created
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 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.

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.
