I Sandboxed My AI Coding Agent with Bubblewrap. It Still Read My Secrets
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 bwrap fails 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:
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[@]}" "$@"
<br>
<br>
!What each bwrap flag does to the filesystem and namespaces pi sees
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:
1. 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.
2. It builds a new root from nothing. The new mount namespace starts with an empty tmpfs root that the host never sees.
3. 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.
4. It switches into that root with pivot_root, so the host's real mounts are no longer reachable.
5. It drops capabilities. From the man page: "By default no caps are left in the sandboxed process." Even as root.
6. 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.
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.
The tracer: 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
}
<br>
What each part does:
- BEGIN { ... } runs once when bpftrace starts and prints the column header.
- tracepoint:sched:sched_process_exec is 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. 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 is 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.
- pid and real_parent->tgid are the process and its parent, as the host numbers them.
- thread_pid->level is how deep the process's PID namespace is nested: 0 for the host, 1 for one sandbox in. This is the LVL column.
- nsproxy->..._ns->ns.inum reads the namespace IDs. 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 comes from the process's credentials instead of nsproxy, 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):
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]
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.
#!/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 |
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.
- NSpid lists all of them, from the outermost to the innermost. The outermost is the namespace of whoever mounted the /proc you'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 /proc was mounted from inside, so its outermost level is the sandbox itself. That's why the probe prints a small number like 17.
- It's the same depth as the tracer's LVL. thread_pid->level is 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.
!Run A: every check reaches the host
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
What I observed in run A
- Same namespaces as my shell. LVL was 0, and every namespace ID matched the baseline.
- NSpid had 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, ~/.bashrc included. 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. nsprobe ignores arguments, so --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."
- 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.
!Run B: writes outside the project fail; the token and network still get out
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
The probe:
== 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
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 nsprobe started id, grep, seven readlinks, ls, wc, four touches, rm, head and curl. Every one inherited the same namespaces. A sandbox applies to a process and everything it ever spawns.
- ~/.bashrc failed with Read-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 / (needs CAP_SYS_ADMIN) or override permissions (needs CAP_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 /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.
- 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-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 "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.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.
---
Run C - Same Sandbox, Launched from ~
The policy didn't change. Only the directory I started from did.
![Run C: launched from ~, ~/.bashrc becomes writable] (dev-to-uploads.s3.us-east-2.amazonaws.com)
The probe:
== 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
What I observed in run C
- Still sandboxed. New PID and mount namespaces (fresh IDs, since it's a new sandbox), NSpid of 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.
- Every write succeeded, ~/.bashrc included. --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 ~/.ssh if it existed. The ~/.pi bind on top became redundant.
- A writable ~/.bashrc is 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 $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.
---
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:
!Run D: with the network cut, pi never reached its model and nothing ran
Error: Connection error.
Error: Connection error.
Error: Connection error.
Error: Connection error.
Error: 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.53 lives 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.com but its curl may 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-run scope 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.
---
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-bind and --bind gave 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.
---
References
- bubblewrap - GitHub and its bwrap(1) man page
- systemd-run(1)
- bpftrace
- pid_namespaces(7), proc_pid_status(5) for NSpid
- mount_namespaces(7), network_namespaces(7)
- Configure the sandboxed Bash tool - Claude Code docs
- Isolation in Containers. Linux Primitives that help Achieve It
Until Claude Code 2.1.309, Your ~/.zshenv Ran Outside the Sandbox Before Each Command
- Book: AI Agents Pocket Guide
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
---
You turned on the sandbox. Every npm test and git status the agent ran was supposed to sit inside an OS-enforced box: writes limited to the project, network only through an allowlist. And then, before each of those commands, a few lines of your own shell config ran on the other side of the wall.
That is the first bug fix in Claude Code 2.1.309, released October 22, 2026: "Fixed sandboxed Bash commands on macOS and Linux running the shell's startup files outside the sandbox before each command."
One line in a list of 62 fixes. It is worth a closer look, because the reason it could happen at all is old shell behavior that most of us never think about. Any tool that wraps your shell in a sandbox runs into it.
What the sandbox is supposed to cover
The Claude Code Bash sandbox is off by default. You turn it on with /sandbox or sandbox.enabled: true. On macOS it uses Apple's Seatbelt framework. On Linux and WSL2 it uses bubblewrap. Writes are limited to the working directory, a per-user temp directory and folders you add. Network traffic goes through a local proxy that checks each host against your allowed domains, a list that starts empty.
The docs also say the shell commands Claude runs "start inside its boundary, and so do the processes they start." Anything that runs before that boundary exists is outside it.
Which code runs before your command is up to your shell.
Your shell runs code you didn't type
Claude Code runs Bash tool commands in bash or zsh. The environment variable docs say auto-detection uses your $SHELL when it points to one of those two. If your login shell is zsh, your agent's commands run in zsh.
Here is the order zsh reads its files, from the zsh manual:
1. /etc/zshenv, which "cannot be overridden"
2. $ZDOTDIR/.zshenv (usually ~/.zshenv)
3. .zprofile, only for login shells
4. .zshrc, only for interactive shells
5. .zlogin, only for login shells
Steps 1 and 2 have no condition. A throwaway zsh -c 'git status' is neither a login shell nor interactive, and it still reads ~/.zshenv. The manual says it plainly about the system file: it "is run for all instances of zsh". From the command line, the way out is zsh -f, which skips everything after /etc/zshenv.
Bash has a different hook with the same effect. Per the bash manual, a non-interactive bash "looks for the variable BASH_ENV in the environment" and runs the file it names. If you, or a tool you installed, exported BASH_ENV, every bash -c runs that file first.
sh is the quiet one. Bash invoked as sh reads $ENV only when it is interactive, so sh -c runs no startup file at all.
!A shell process opening a door in a sandbox wall, with a small config file slipping through before the door shuts
The shell reads its startup files the moment it starts. Whether that is inside the box depends on what started first.
Which side of the wall the shell starts on
Any sandbox that wraps a shell command has to pick an order. Here are the two shapes, with a made-up sandbox-run standing in for the real wrapper (Seatbelt or bubblewrap):
# Shell first: zsh reads ~/.zshenv, THEN the
# sandbox starts around the command.
zsh -c 'sandbox-run -- npm test'
# Sandbox first: the box exists before zsh
# starts, so ~/.zshenv runs inside it.
sandbox-run -- zsh -c 'npm test'
In the first shape, ~/.zshenv runs with your full user rights: it can write anywhere you can and open network connections no proxy sees. Only npm test is confined.
The changelog doesn't say how Claude Code's wrapper was built before 2.1.309, so I won't guess at its internals. What it does say is the outcome: on macOS and Linux, the startup files ran outside the sandbox, before each command. From 2.1.309, they no longer run outside it.
How bad that was depends on what is in your files. For most people ~/.zshenv sets PATH and a couple of variables, and nothing about that is dangerous. It matters when the file does more: fetches a token from a CLI, calls a version manager that checks the network, writes a cache, or sources something from a folder you don't control. All of that ran unconfined, once per command, with the agent choosing when commands run.
There is a related fix in 2.1.304 (October 19): unsandboxed commands in Claude Desktop's Code tab weren't seeing variables exported in startup files such as ~/.zshrc. Agents depend on those files to get a working PATH, which is why a wrapper can't just skip them.
See it on your own machine
Add one line to the top of ~/.zshenv. It appends a timestamp, the shell's PID and the first 50 characters of the -c string (zsh keeps it in ZSH_EXECUTION_STRING) to a log. Errors are thrown away so a failed write never breaks a command.
# TEMPORARY trace, delete when done
{ print -r -- "$(date +%T) $$ ${ZSH_EXECUTION_STRING[1,50]}" \
>> ~/zshenv-trace.log } 2>/dev/null
Check that it fires for a non-interactive shell, and that -f skips it:
zsh -c 'true'
zsh -f -c 'true'
cat ~/zshenv-trace.log
You should see one new line, from the first command. Now start a Claude Code session, ask it to run your tests, look around the repo and check git status. Then count:
wc -l ~/zshenv-trace.log
Each line is one time your ~/.zshenv ran. The number depends on how many commands the agent ran and what Claude Code starts for itself, so I'm not going to give you one. Run it and see what you get.
One thing to read in the result: the sandbox only allows writes to the project, the temp directory and folders you add, so a shell started inside it can't append to a file in your home folder. On 2.1.309 or later with the sandbox on, sandboxed commands can't write to your home folder (unless you added it as a write path), so their lines won't show up in the log. That gap is a sign the file no longer runs outside the box. Any line that does appear came from a shell started outside the sandbox.
For bash, the same check uses BASH_ENV:
printf 'echo "BASH_ENV ran in $$" >&2\n' > /tmp/env-demo
BASH_ENV=/tmp/env-demo bash -c 'echo hi'
ENV=/tmp/env-demo sh -c 'echo hi'
The bash line runs the file before echo hi. The sh line doesn't, because $ENV is for interactive shells only. Delete the trace line from ~/.zshenv when you are done.
Audit what your startup files actually do
Updating closes this hole. It doesn't change the fact that these files run for every agent command, inside the box or not. So read them:
grep -nE 'curl|wget|eval|source|TOKEN|KEY|SECRET' \
~/.zshenv ~/.zprofile ~/.zshrc ~/.bashrc 2>/dev/null
What to look for, and where it belongs:
- Secrets exported as variables. A token exported in ~/.zshenv is re-exported by every shell the agent starts, deny list or not. Move it to ~/.zshrc, or out of your shell config entirely. The sandbox docs also say sandboxed commands inherit Claude Code's environment, "including any secrets in its environment." For those, deny the ones an agent doesn't need with sandbox.credentials.envVars and "mode": "deny", which unsets them before each sandboxed command.
- Network calls. A curl, a plugin manager update check, or a gh auth token lookup has no reason to run for every non-interactive shell. Move it to ~/.zshrc, which zsh -c doesn't read.
- eval "$(some-tool init)" lines. These run a program and execute whatever it prints. If an agent command doesn't need the tool, move the line to ~/.zshrc.
- PATH and plain exports. These are what ~/.zshenv is for. Keep them there.
Then update. claude --version tells you where you are, and claude update gets you current. If you're below 2.1.309, you don't have the fix.
Where Hermes fits
I build Hermes IDE, so weigh this with that in mind. Hermes runs Claude Code, Codex and other agents each in a real terminal, running the agent's own interface. The sandbox in this post is Claude Code's, configured in Claude Code's settings, and it works the same in Hermes as in any terminal.
Where it helps here is small. The ⌘N agent picker shows which agents are installed and the version of each, so an old Claude Code is easy to spot before you start a task with it.
!The New task sheet's agent picker listing Claude Code as installed with its version number, Codex installed, and other agents marked Not installed
The ⌘N New task sheet with its agent picker open: Claude Code (installed, 2.1.299) selected, Codex installed (0.145.0), and the other supported agents marked Not installed.
That build predates the fix, which is the point of checking. Hermes is source-available under BSL 1.1 and runs on macOS, Windows and Linux.
---
If this was useful
Any agent harness can hit the same gap: the guardrail wraps the command, and the setup around it runs with your full rights. My AI Agents Pocket Guide covers where those boundaries sit in an agent loop, and how to check what really runs inside them.
You can download Hermes IDE or read the source on GitHub.

No Docker Desktop? WSL2 + Podman on Windows: setup and the errors I hit
Two ways to run containers on Windows without Docker Desktop, plus every red error message I met on the way.
Here is the situation. Your laptop is locked down, Docker Desktop is not on the approved list (or the license is a "talk to finance" thing), and you still need to run docker build before lunch. Good news: you do not need Docker Desktop. Bad news: the two replacements each have their own little traps, and one of them quietly hands you root.
TL;DR
- Option 1: WSL2 + Docker Engine. Install Ubuntu in WSL, apt install docker.io, done. Feels exactly like Docker. Watch out for systemd and the docker group.
- Option 2: Podman. No daemon, rootless by default, same commands (alias docker=podman). Watch out for low ports and the Docker named pipe.
- Security gotcha: "fixing sudo" by joining the docker group gives you root-level power. Rootless Podman and real secrets beat that.
!Two ways to run containers on Windows without Docker Desktop
Table of contents
- [1. Pick your path](#1-pick-your-path)
- [2. Option 1 WSL2 and Docker Engine](#2-option-1-wsl2-and-docker-engine)
- [3. Option 2 Podman on Windows](#3-option-2-podman-on-windows)
- [4. The errors I hit and how to fix them](#4-the-errors-i-hit-and-how-to-fix-them)
- [5. Security gotcha](#5-security-gotcha)
- [6. Checklist](#6-checklist)
- [Sources](#sources)
1. Pick your path
Both options use WSL2 under the hood. The difference is who runs the containers.
| | WSL2 + Docker Engine | Podman |
|---|---|---|
| Where it runs | Docker daemon inside your Ubuntu distro | A small Podman machine (a WSL2 distro) |
| Daemon | Yes, dockerd running as root | No central daemon |
| Default user | root daemon, you call it via sudo or the docker group | Rootless |
| Commands | docker ... | podman ... (almost the same) |
| You run it from | The Ubuntu shell | PowerShell, CMD or WSL |
| Best for | "I want my Docker workflow back" | Locked-down or security-minded setups |
My rule of thumb: if your team's scripts are full of docker compose and Docker-only flags, go with Option 1. If you mostly build and run images and your security team frowns at root daemons, go with Podman.
2. Option 1 WSL2 and Docker Engine
This keeps your workflow almost identical to Docker Desktop. You just do the plumbing yourself.
Step 1. Install WSL. Open PowerShell as administrator:
wsl --install
Restart the machine. You need Windows 10 version 2004 (build 19041) or newer, or Windows 11. By default this installs Ubuntu and sets new installs to WSL 2. Check it:
wsl -l -v
You want to see VERSION 2 next to your distro.
Step 2. Install (or pick) a distro. Ubuntu is the default, but you can ask for it by name, and list the others:
wsl --install -d Ubuntu
wsl --list --online
Step 3. Start Ubuntu.
wsl -d Ubuntu
Step 4. Install Docker Engine inside WSL (no Desktop):
sudo apt update
sudo apt install -y docker.io
sudo systemctl enable docker
sudo systemctl start docker
Step 5. Test.
sudo docker run hello-world
If you see "Hello from Docker!", you have a working engine. Notice the sudo though. We will come back to that, because that is where people (me included) take a shortcut with a security price tag.
Pros: same commands, same Dockerfiles, and an easy path to Minikube later. Cons: you manage the engine yourself, and it is a little less smooth than Desktop.
Source: Install WSL
3. Option 2 Podman on Windows
Podman is daemonless: there is no big root process waiting for orders. On Windows, Podman runs inside a "Podman machine", which is a WSL2 distro it manages for you. The podman command in PowerShell talks to it.
Step 1. Install Podman. The official way is the Windows installer from the Podman GitHub releases page. The quick way is winget:
winget install RedHat.Podman
Close and reopen the terminal so podman.exe is on your PATH.
Step 2. Create and start the Podman machine.
podman machine init
podman machine start
If WSL is not installed yet, podman machine init offers to install it for you, reboots, and carries on after you log in. Nice, but do not be surprised by the reboot.
Step 3. Test.
podman run hello-world
If a short image name like hello-world makes Podman ask which registry you mean, give it the full name: docker.io/library/hello-world.
Step 4. Use your Docker muscle memory.
podman build -t myapp .
podman run myapp
alias docker=podman # in a bash shell
In PowerShell the equivalent is Set-Alias docker podman (add it to your profile to keep it).
Pros: no daemon, rootless by default, Docker-compatible CLI, and security teams like it. Cons: some edge cases with networking and volumes, and Kubernetes integration is a bit more work.
Source: Podman installation, Podman for Windows guide
4. The errors I hit and how to fix them
The fun part. Here is what broke, why, and the fix.
Error 1. wsl --install just prints the help text.
Why: WSL is already installed, so the "install everything" path does not run.
Fix: list what is available and install a distro by name:
wsl --list --online
wsl --install -d Ubuntu
If the download hangs at 0.0%, add --web-download: wsl --install --web-download -d Ubuntu.
Error 2. System has not been booted with systemd as init system (PID 1). Can't operate.
Why: systemctl needs systemd, and some distros or older WSL builds still use the old WSL init. The current Ubuntu from wsl --install has systemd on by default, so you mostly hit this with other distros.
Fix: check your WSL version is 0.67.6 or newer, then add this to /etc/wsl.conf inside the distro:
[boot]
systemd=true
Then restart WSL (see Error 4). Confirm with systemctl status.
Error 3. permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock
Why: the Docker socket belongs to root and the docker group. Your user is in neither, so plain docker ps gets the door slammed.
Fix: add yourself to the group, then restart WSL so the new group sticks:
sudo usermod -aG docker $USER
wsl --shutdown
wsl -d Ubuntu
Then docker ps works without sudo. Here is my lab after the fix: empty table, no error, no sudo.
!docker ps works without sudo after usermod and wsl --shutdown
Before you celebrate, read section 5. This fix has a catch.
Error 4. I changed a setting and nothing happened.
Why: WSL only picks up wsl.conf changes after the distro fully stops. Microsoft calls it the "8 second rule": close every shell, then wait about 8 seconds.
Fix: wsl --list --running should say there are no running distributions. Or use the big hammer: wsl --shutdown. It stops all distros, including your Podman machine, so save your work first.
Error 5. Rootless Podman will not bind port 80.
Why: rootless containers cannot bind ports below 1024.
Fix: map to a higher host port (-p 8080:80), which is what I do on a dev laptop anyway. Only if you really need port 80, stop the machine and switch it to rootful:
podman machine stop
podman machine set --rootful
podman machine start
podman machine set --rootful=false takes you back.
Error 6. Docker tools talk to the wrong engine.
Why: Podman listens on the same named pipe Docker clients use by default, npipe:////./pipe/docker_engine. If something else already holds that pipe, the two fight.
Fix: point your tools at Podman's own pipe by setting DOCKER_HOST to npipe:////./pipe/podman-machine-default.
Error 7. podman machine init fails after the automatic WSL install.
Why: the auto-install sometimes gets stuck halfway.
Fix: install WSL manually with wsl --install from an admin PowerShell, reboot, and run podman machine init again. Also check that virtualization is enabled: WSL needs it, and a VM host needs nested virtualization.
Source: WSL config, 8 second rule, systemd in WSL
5. Security gotcha
This is the DevSecOps bit. Read it before you copy any of the fixes above to a shared machine.
1. The docker group is root with extra steps. Docker's own docs say it plainly: the docker group grants root-level privileges. Anyone in it can start a container that mounts / from the host and edit anything. On your personal lab laptop, fine. On a shared build box, think twice before running usermod -aG docker for everyone.
2. Rootless is the safer default, so keep it. Podman machines start rootless. --rootful exists, but switching to it just to get port 80 trades a small annoyance for a bigger blast radius. Map to 8080 instead.
3. Your Windows drives are inside WSL. By default WSL mounts C:\ at /mnt/c. A container started with -v /mnt/c:/host can read your whole user profile, browser data included. Mount only the project folder you need.
4. Secrets in compose files end up in Git. environment: DB_PASSWORD=SuperSecret123 in a compose file is how passwords travel to a public repo. Keep secrets out of the file: use a .env file listed in .gitignore, or Podman secrets, which are mounted as a file and never baked into an image:
printf 'my-lab-password' | podman secret create db_password -
podman run --secret db_password docker.io/library/alpine cat /run/secrets/db_password
5. License is a security-adjacent problem too. Docker Desktop is free for personal use and small businesses (under 250 employees and under $10M revenue). Bigger companies need a paid plan. Unlicensed tools on corporate laptops tend to get noticed by the same people who run your security reviews.
Source: Docker post-install steps (docker group warning), podman secret create
6. Checklist
- [ ] wsl -l -v shows my distro on VERSION 2
- [ ] systemctl status works inside Ubuntu (systemd on)
- [ ] docker run hello-world or podman run hello-world prints its hello
- [ ] I know the docker group equals root, and I only use it on my own lab machine
- [ ] My Podman machine is still rootless, and I map ports above 1023
- [ ] I mount project folders, not the whole /mnt/c
- [ ] No passwords in compose files: .env is in .gitignore or I use podman secret
- [ ] I run wsl --shutdown knowing it stops every distro, Podman included
Which did you pick on your Windows machine, WSL2 + Docker or Podman? And what was your first red error? Tell me below.
Sources
- Microsoft Learn: Install WSL
- Microsoft Learn: Use systemd to manage Linux services with WSL
- Microsoft Learn: Advanced settings configuration in WSL
- Podman docs: Installation
- Podman for Windows guide
- Podman docs: podman machine set
- Podman docs: podman secret create
- Docker docs: Linux post-installation steps
- Docker docs: Docker Desktop license agreement
skopeo ⭐ 11k
Skopeo is a command-line tool for developers and operators to copy, inspect, and manage container images across registries.
🔗 containers/skopeo
#opensource t.co
14 views
Claude Code's Sandbox Is an npm Package. Run srt Yourself and Watch What It Blocks
- Book: AI That Acts
- The series: AI in TypeScript — 5 books, from your first LLM call to agents in production — all five here
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
---
Turn on Claude Code's sandbox and ask Claude to run cat ~/.ssh/known_hosts. The command goes through. That isn't a bug. Claude Code's sandboxing docs say a sandboxed command can read "most of the machine, including credential files such as ~/.ssh and ~/.aws/credentials" until you deny them.
The same page has a line most people scroll past: "Claude Code builds the sandbox on the open source @anthropic-ai/sandbox-runtime package." That package ships a CLI called srt. You can run it without Claude Code, wrap any command in it, and see for yourself which layer refuses what. It's a fast way to learn what /sandbox promises before you let an agent run with fewer prompts.
@anthropic-ai/sandbox-runtime is Apache-2.0, and its README calls it a "Beta Research Preview" whose "APIs and configuration formats may evolve." The npm registry lists version 0.0.80, which needs Node.js 22.12.0 or newer.
There is no Docker image and no VM. On macOS it runs your command through sandbox-exec with a Seatbelt profile it generates. On Linux it uses bubblewrap. Windows support exists and the README marks it alpha. The network goes through two proxies, an HTTP proxy and a SOCKS5 proxy, that run on the host outside the sandbox and check every host against your lists. The sandboxed process has no other way out: on Linux "the network namespace of the sandboxed process is removed entirely," and on macOS "the Seatbelt profile allows communication only to a specific localhost port."
Anthropic introduced it in Beyond permission prompts on October 20, 2025. The post says the limits cover "any scripts, programs, or subprocesses that are spawned by the command," and that "in our internal usage" sandboxing cut permission prompts by 84%. That figure is Anthropic's own measurement, not an independent one.
Install it and write a policy
npm install -g @anthropic-ai/sandbox-runtime
# macOS: srt looks for ripgrep on your PATH
brew install ripgrep
# Debian and Ubuntu
sudo apt-get install bubblewrap socat ripgrep
ripgrep is how srt finds the paths it must deny, and socat relays traffic to the proxies on Linux. Install srt globally, not into your project. The README's security notes give the reason: a copy in <project>/node_modules/ with the project writable "can be rewritten by a sandboxed command, and the next srt there runs the result unsandboxed." On Ubuntu 24.04 and later, AppArmor stops bubblewrap from creating user namespaces by default, and the Claude Code docs have the profile that allows it.
With no settings file, srt starts anyway, with no network and writes confined to a few runtime paths such as /tmp/claude. Claude Code's sandbox environments page puts it plainly: "Don't take a clean start as proof your settings loaded." Save a policy as ~/.srt-settings.json:
{
"network": {
"allowedDomains": ["registry.npmjs.org"],
"deniedDomains": []
},
"filesystem": {
"denyRead": ["~/.ssh"],
"allowWrite": ["."],
"denyWrite": []
}
}
The config schema requires all five lists, even the empty ones. If the file exists but is empty or invalid, srt refuses to start, which is what you want from a security tool. Reads are allowed everywhere unless you deny them. Writes are denied everywhere unless you allow them. Network is denied unless the host is on allowedDomains, and deniedDomains is checked first.
One probe, run twice
Make a throwaway git repo, so there is a .git/hooks folder to aim at, and save this in it as probe.sh:
#!/usr/bin/env bash
# probe.sh: run it plain first, then under srt
check() {
if "$@" >/dev/null 2>&1; then echo "ok $*"
else echo "FAIL $*"; fi
}
check cat "$HOME/.ssh/known_hosts"
check touch "$HOME/srt-probe"
check touch ./srt-probe
check touch .git/hooks/srt-probe
check curl -fsS https://registry.npmjs.org/left-pad
check curl -fsS https://example.com
check curl -fsS --noproxy '*' https://registry.npmjs.org/left-pad
mkdir -p ~/code/srt-lab && cd ~/code/srt-lab && git init
# save probe.sh here, then:
bash probe.sh
srt bash probe.sh
rm -f ~/srt-probe ./srt-probe .git/hooks/srt-probe
The plain run is your baseline. Every line should say ok, unless you have no known_hosts file. In the second run, srt starts bash, and every cat, touch and curl that bash starts inherits the same limits.
Pass the command as separate words, as above. In the current CLI source, positional arguments are shell-quoted before srt runs them with bash -c, so srt "bash probe.sh" would look for a program literally named bash probe.sh. For a line with pipes, redirects or ~, use srt -c '...', which the source describes as "run command string directly (like sh -c), no escaping applied."
Here is what each line should do under srt, going by the README and Claude Code's docs. I'm working from those, not from a run of my own, so trust your terminal over this table:
| Probe | Under srt | What stops it |
|---|---|---|
| cat ~/.ssh/known_hosts | FAIL | denyRead, enforced by the OS |
| touch ~/srt-probe | FAIL | Writes are allow-only, home isn't listed |
| touch ./srt-probe | ok | allowWrite: ["."] |
| touch .git/hooks/srt-probe | FAIL | Built-in deny, even inside . |
| curl registry.npmjs.org | ok | Host is allowed, proxy passes it |
| curl example.com | FAIL | Proxy refuses the host |
| curl --noproxy '*' | FAIL | OS layer: no route but the proxy |
The README shows a denied read failing with Operation not permitted. For a write outside the boundary, Claude Code's docs list Operation not permitted on macOS and Read-only file system on Linux and WSL2, and they show a --noproxy curl failing with Could not resolve host. A refused HTTP request gets a 403 from the proxy with the header X-Proxy-Error: blocked-by-sandbox-runtime, per the README.
Two layers, and the gap between them
The last two curl lines are the whole design. The proxy decides by hostname. Claude Code's docs say it "checks the hostname of each connection against your allowed and denied domains" and, by default, "does not terminate or inspect TLS." They also warn that broad entries such as github.com "can create paths for data exfiltration," and the README adds that domain fronting may get past the filter. Treat every allowed domain as a place your data can go.
The OS layer is why ignoring the proxy doesn't help. The proxy reaches tools through HTTP_PROXY, HTTPS_PROXY and ALL_PROXY. A tool that ignores them has no route at all, which is also why, per Claude Code's docs, plain ssh and most database drivers "can't connect, even to an allowed host," and UDP, QUIC and ping can't leave either.
The .git/hooks line shows the other built-in rule. Some paths are write-denied even inside an allowed folder, among them shell startup files, .gitconfig, .git/hooks, .git/config, .vscode, .idea, .mcp.json, .claude/commands and .claude/agents. Those are the files where a sandboxed command could plant code that something unsandboxed runs later. One caveat from the sandbox environments page: on Linux the runtime builds that deny list once at launch, so it doesn't cover a repo the session creates later with git init or git clone.
Inside Claude Code, /sandbox wraps shell commands only. The docs list what runs outside it: the Read, Edit, WebFetch and WebSearch tools, hooks, local MCP servers and LSP servers. "A denyRead entry doesn't stop the Read tool." The sandbox is also off by default, and if a dependency is missing, Claude Code runs commands unsandboxed unless you tell it to stop. Put this in ~/.claude/settings.json to close those doors:
{
"sandbox": {
"enabled": true,
"failIfUnavailable": true,
"allowUnsandboxedCommands": false,
"filesystem": { "denyRead": ["~/.ssh", "~/.aws"] }
}
}
allowUnsandboxedCommands: false turns off the retry where Claude asks to rerun a failed command outside the sandbox. For the Read tool you still need a deny rule in permissions. To put hooks and MCP servers behind the same wall, the docs suggest running all of Claude Code under srt with npx @anthropic-ai/sandbox-runtime claude. The sandbox environments page lists the paths and domains your srt settings must allow first.
Put srt around your own agent's tool calls
srt is also a library. If you build an agent in TypeScript and give the model a shell tool, wrap the tool's handler. This follows the README's library example:
// sandboxed-shell.mts
import { spawn } from 'node:child_process'
import {
SandboxManager,
type SandboxRuntimeConfig,
} from '@anthropic-ai/sandbox-runtime'
const config: SandboxRuntimeConfig = {
network: {
allowedDomains: ['registry.npmjs.org'],
deniedDomains: [],
},
filesystem: {
denyRead: ['~/.ssh'],
allowWrite: ['.'],
denyWrite: [],
},
}
await SandboxManager.initialize(config)
initialize() starts the proxies once, at startup. The tool handler goes in the same file:
// Handler for a "run_shell" tool the model can call
export async function runShell(cmd: string): Promise<string> {
const wrapped = await SandboxManager.wrapWithSandbox(cmd)
return new Promise((resolve) => {
const child = spawn(wrapped, { shell: true })
let out = ''
child.stdout.on('data', (d) => (out += d))
child.stderr.on('data', (d) => (out += d))
child.on('close', (code) => resolve(`exit ${code}\n${out}`))
})
}
// On shutdown: await SandboxManager.reset()
wrapWithSandbox() returns the command line that runs your string inside the boundary. The model gets Operation not permitted back as the tool result and can change course, and nobody has to click Approve. Run the agent from a workspace folder that isn't the folder holding your app and its node_modules, for the same reason the README gives about installing srt.
Where Hermes IDE fits
I build Hermes IDE, so weigh this section with that in mind. By default, Hermes opens each agent in a real terminal running the agent's own interface. /sandbox is Claude Code's own command, so in that terminal it opens the same panel and enforces the same boundary. Hermes also has an opt-in Agent view for Claude that renders the conversation in place of Claude's terminal interface, and this post doesn't cover that mode. Codex brings its own sandbox, and OpenAI's sandboxing page says it uses Seatbelt on macOS and bubblewrap on Linux and WSL2 too.
!Codex and Claude Code sessions side by side in Hermes IDE, each in its own hermes/ worktree, with the sessions sidebar on the left
Two real agent sessions side by side: Codex on orders-types and Claude Code on orders-api, each in its own hermes/ worktree, with the sessions sidebar (status, context use, cost, branch) on the left.
Hermes adds no sandbox of its own. A worktree per task keeps agents out of each other's files, and your home directory is still the sandbox's job. Claude Code's sandbox does know about worktrees: in a linked worktree it also allows writes to the main repository's shared .git, so git commit works, while hooks/ and config in there stay write-denied.
When the sandbox needs a decision, such as a new domain in manual or accept-edits mode, or an unsandboxed retry, the prompt appears in Claude Code's terminal. For an agent blocked on a prompt like that, Hermes shows the session as needing approval (and says when that status is a guess), the attention inbox counts the agents blocked on you, and ⌘I (Ctrl+Shift+I on Windows and Linux) jumps to the agent that has waited longest. You answer in that agent's own terminal. Hermes never answers a permission prompt for you and never types into a terminal on its own.
!The Hermes IDE New task sheet with a refund-endpoint task typed, chips for Claude Code, the project, a new worktree, Accept edits, model and effort, and a preview of the claude command Hermes will run
The ⌘N New task sheet: Claude Code, the orders-api project, a new hermes/ worktree, Accept edits, model and effort, and the "Hermes will run" preview of the claude command.
Hermes also runs any command-line agent you add, so npx @anthropic-ai/sandbox-runtime claude can be the command, once your srt settings allow what Claude Code needs. I haven't run that combination myself.
---
If this was useful
The runShell handler above is the moment an agent stops talking and starts acting, and that's what AI That Acts is about: tool calling, functions and a first working agent, in TypeScript. Read it for the tool loop, then put srt around the tool that touches your machine.

It's book 3 of AI in TypeScript, five books from your first LLM call to agents in production: all five here.
Hermes IDE is free, source-available under BSL 1.1, and runs on macOS, Windows and Linux. Download it or star it on GitHub. It isn't affiliated with Anthropic, OpenAI, Nous Research or any other agent vendor.
1 reaction · 1 comment
I Replaced runc with a Rust Container Runtime (youki) for a Week
The Boring Layer That Matters Most
Every container you've ever run — docker run hello-world included — went through a tiny binary called runc before it became a process. It creates the namespaces, sets up the cgroups, drops privileges, and hands off to your entrypoint.
You never think about it. And that's the problem.
runc is written in Go + C (yes, C — for the nsenter and pivot_root bits that Go can't handle cleanly). It's been the reference implementation since 2014. It's also the layer where CVE-2024-21626 (a container escape) lived.
What if that layer was memory-safe by construction?
Enter youki — a container runtime written entirely in Rust, now a CNCF Sandbox project.
---
What Is youki?
youki (Japanese for "container") is an OCI runtime-spec implementation in Rust. It's a drop-in replacement for runc: same CLI, same spec, same integration path through containerd, Podman, or Docker.
| Fact | Detail |
|---|---|
| Language | Rust (pure, except a libseccomp-rs FFI shim being replaced) |
| CNCF status | Sandbox (accepted Oct 17, 2024) |
| GitHub | youki-dev/youki |
| Stars | ~7,300 |
| License | Apache 2.0 |
| Adopters | Azure Kubernetes Service, Docker, containerd/runwasi |
| Contributing orgs | Microsoft, Docker, Red Hat |
The key differentiator vs. runc: no Go runtime startup, no C nsenter binary, no mixed-language memory safety boundary. The entire create → start → delete lifecycle lives in one Rust binary with compile-time safety guarantees.
---
The Benchmarks (Honest Version)
The headline number from youki's own repo:
Runtime Time (mean ± σ) Range (min … max) vs youki
youki 111.5 ms ± 11.6 ms 84.0 ms … 142.5 ms 100%
runc 224.6 ms ± 12.0 ms 190.5 ms … 255.4 ms 200%
crun 47.3 ms ± 2.8 ms 42.4 ms … 56.2 ms 42%
Youki is ~2× faster than runc for create → start → delete. But crun is ~2.5× faster than youki.
Here's the honest context:
| Scenario | Does it matter? |
|---|---|
| Single long-lived container | No. 100ms of startup is noise over a 7-day uptime. |
| Serverless: 10k containers/min | Yes. A 100ms savings × 10k = 1,000 CPU-seconds per minute reclaimed. |
| K8s pod churn (CI/CD, spot nodes) | Moderately. Image pull + CNI + scheduling dominate; runtime is ~5–10% of total. |
| Edge/embedded (tight RAM) | Yes. youki's binary is smaller and RSS is lower per instance. |
The real win isn't raw speed — it's the Rust safety guarantee. You're not betting your node's security on a C memcpy in nsenter.
---
Setup: Replacing runc on a K8s Node
Prerequisites
- Kubernetes 1.28+ with containerd (not Docker Engine)
- Linux kernel 5.10+ (youki drops older kernel support to use modern features)
- cargo / just for building from source (or use a pre-built binary)
Step 1: Build youki
git clone https://github.com/youki-dev/youki.git
cd youki
just build
# Binary: ./target/release/youki
Or grab a release binary from GitHub Releases.
Step 2: Register with containerd
Edit /etc/containerd/config.toml (or use containerd config path):
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.youki]
runtimeType = "io.containerd.youki.v1"
Then install the shim (youki ships a containerd-compatible shim):
sudo cp target/release/youki /usr/local/bin/
sudo cp target/release/containerd-shim-youki-v1 /usr/local/bin/
Restart containerd:
sudo systemctl restart containerd
Step 3: Create a RuntimeClass
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
name: youki
handler: youki
scheduling:
nodeSelector:
kubernetes.io/arch: amd64
kubectl apply -f runtimeclass-youki.yaml
Step 4: Run a Pod with youki
apiVersion: v1
kind: Pod
metadata:
name: youki-test
spec:
runtimeClassName: youki
containers:
- name: app
image: nginx:alpine
resources:
requests:
memory: "32Mi"
cpu: "10m"
kubectl apply -f youki-pod.yaml
kubectl get pod youki-test -o wide
# youki-test 1/1 Running 0 2s
Verify it's actually using youki:
kubectl exec youki-test -- cat /proc/1/cgroup
# Confirms cgroup v2 hierarchy managed by youki
---
Step 5: Run It for a Week (What I Noticed)
I ran a 3-node k3s cluster with a mix of workloads:
- 20 long-lived API pods (Go, ~50 MB each)
- A CI pipeline spawning 50–200 short-lived pods/day
- A cron job that scales a batch processor 0 → 10 → 0 every hour
What went well:
1. Zero crashes, zero OOMs in the runtime itself. The Rust error handling is visible in logs — when something goes wrong, you get a structured Error with context, not a segfault.
2. Short-lived pods were noticeably snappier. My CI pods (build → test → delete) came up ~30–50ms faster than runc. Not dramatic per-pod, but across 200 pods/day it added up.
3. Rootless mode was cleaner. youki create with --rootless worked without the newuidmap/subuid gymnastics that runc sometimes needs.
4. Binary size. youki: ~8 MB. runc: ~12 MB. crun: ~3 MB. On an edge node with 512 MB RAM, every MB counts.
What was rough:
1. cgroups v1 support is there but v2 is the path of least resistance. If your distro still defaults to v1 (looking at you, CentOS 7), you'll hit edge cases.
2. No --checkpoint/--restore (CRIU) yet. If you rely on live migration or pause/resume, you're stuck on runc.
3. Docs are thin. The user docs cover the basics but there's no "migrating a production cluster from runc to youki" guide. You're on your own.
4. One C dependency remains. libseccomp-rs is an FFI wrapper around the C libseccomp. The team is working on a pure-Rust replacement, but it's not done.
---
youki vs. runc vs. crun: Where Each Wins
| Dimension | runc | crun | youki |
|---|---|---|---|
| Language | Go + C | C | Rust |
| Memory safety | ❌ (C portion) | ❌ | ✅ (99% — one FFI shim) |
| Create→delete (benchmark) | 224 ms | 47 ms | 111 ms |
| Binary size | ~12 MB | ~3 MB | ~8 MB |
| cgroups v2 | ✅ | ✅ | ✅ |
| Rootless | ✅ | ✅ | ✅ (cleanest) |
| CRIU (checkpoint/restore) | ✅ | ✅ | ❌ (roadmap) |
| Wasm support | ❌ | ❌ | ✅ (first-class) |
| CNCF governance | ❌ (OCI) | ✅ (Sandbox) | ✅ (Sandbox) |
| Production battle-testing | 10+ years | 5+ years | ~3 years |
| Best for | Default, compatibility | Max performance, minimal footprint | Safety + Wasm + future-proofing |
The honest takeaway: If you need maximum raw speed today, crun wins. If you need the safest runtime with a path to Wasm workloads, youki is the pick. runc remains the "it just works" default.
---
The Wasm Angle (Why This Matters More Than You Think)
Here's the part that excites me: youki is the low-level runtime that powers runwasi (the containerd shim for Wasm workloads) and, by extension, SpinKube.
The architecture:
containerd
└── containerd-shim-runwasi (Rust)
└── youki libcontainer (Rust) ← shared library
└── Wasmtime / Wasmer / WasmEdge
Youki's libcontainer crate is used as a library by other Rust container tools. That means the same memory-safe, OCI-compliant primitives that run your nginx:alpine pod also run your Wasm modules. One codebase, two workload types, same security guarantees.
This is the "Rust container stack" taking shape:
| Layer | Rust project | Role |
|---|---|---|
| Runtime | youki | OCI container lifecycle |
| Shim | runwasi | Wasm on containerd |
| Orchestrator | SpinKube | Wasm on Kubernetes |
| Image tooling | naftiko | Build OCI/Wasm images |
| Spec | oci-spec (Rust crate) | The spec itself, in Rust |
Youki is the foundation. If it's not solid, nothing above it is.
---
What's Missing (and Where Funding Helps)
youki is in CNCF Sandbox — the earliest maturity level. The project is healthy (active commits, 80+ contributors, backing from Microsoft/Docker/Red Hat) but under-resourced for what it needs to do:
- Pure-Rust seccomp (eliminate the last C dependency) — significant engineering effort
- SELinux in Rust — needed for RHEL/CentOS production adoption
- CRIU support — checkpoint/restore is table-stakes for production
- OCI conformance test suite — a dedicated, automated compliance harness
- Docs and migration guides — "how do I move a 500-node cluster from runc to youki?"
- Security audit — a formal audit would be a major trust signal for enterprise adoption
- Conference presence — KubeCon, RustConf, containersc — the project needs more visibility
No LFX Crowdfunding campaign is active yet. If you use or evaluate youki, the highest-impact things you can do:
1. Contribute — even docs, test cases, or bug reports. GitHub
2. Run it in production and report what breaks (or doesn't)
3. Write about it — this article is a start, but we need more "I ran youki in a real cluster" content
4. Watch for an LFX Crowdfunding launch — CNCF funding page
---
TL;DR
| Question | Answer |
|---|---|
| Is youki production-ready? | For new, non-critical workloads: yes. For a 500-node cluster: not yet. |
| Is it faster than runc? | ~2× for create/delete. Yes, but crun is faster still. |
| Is it safer than runc? | Yes — memory-safe Rust eliminates the C nsenter attack surface. |
| Should I switch today? | If you're running Wasm workloads or want to be early on the Rust container stack: yes. Otherwise: watch this space. |
| What's the risk? | It's a Sandbox project. The API could shift. CRIU is missing. Docs are thin. |
---
Final Thought
The container runtime is the most underappreciated layer in the stack. It's the thing that's always running, that touches every syscall, that sits between your workload and the kernel. For a decade, that layer was a Go binary with a C sidecar.
Now there's a Rust option with CNCF governance, major-company backing, and a clear path to being the foundation for the next generation of container workloads (including Wasm).
I ran it for a week. It didn't break. It was faster. And the Rust error messages in the logs were genuinely pleasant to read.
That's about as good as "boring infrastructure" gets.
---
Have you tried youki in production? What broke? What didn't? Drop your experience in the comments — I'm especially curious about anyone running it alongside runwasi or SpinKube.
If you want to support the project, the best things right now are contributing code/docs and writing about your experience. The CNCF funding infrastructure is ready when the project is.
1 reaction
And Linux would like to present you bubblewrap [1][2][3][4] or the infamous SELinux.
[1]: containers/bubblewrap
[2]: man.archlinux.org
[3]: wiki.archlinux.org
[4]: wiki.archlinux.org
Today I learnt that until recently @Podman_io didn't allow multiple env files as arguments like Docker, however this functionality has recently been added. podman-compose#1499 #Docker #Podman
If it's their own computer, what better way for them to learn than by making mistakes? And if it's a shared computer, Linux container tools (e.g. containers/toolbox) would let them "sudo rm -rf /" in their own environment without jeopardizing the base system or other users.
Mentions
I Sandboxed My AI Coding Agent with Bubblewrap. It Still Read My Secrets 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 bwrap fails 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: 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[@]}" "$@" <br> <br> !What each bwrap flag does to the filesystem and namespaces pi sees 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: 1. 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. 2. It builds a new root from nothing. The new mount namespace starts with an empty tmpfs root that the host never sees. 3. 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. 4. It switches into that root with pivot_root, so the host's real mounts are no longer reachable. 5. It drops capabilities. From the man page: "By default no caps are left in the sandboxed process." Even as root. 6. 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. 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. The tracer: 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 } <br> What each part does: - BEGIN { ... } runs once when bpftrace starts and prints the column header. - tracepoint:sched:sched_process_exec is 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. 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 is 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. - pid and real_parent->tgid are the process and its parent, as the host numbers them. - thread_pid->level is how deep the process's PID namespace is nested: 0 for the host, 1 for one sandbox in. This is the LVL column. - nsproxy->..._ns->ns.inum reads the namespace IDs. 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 comes from the process's credentials instead of nsproxy, 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): 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] 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. #!/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 | 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. - NSpid lists all of them, from the outermost to the innermost. The outermost is the namespace of whoever mounted the /proc you'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 /proc was mounted from inside, so its outermost level is the sandbox itself. That's why the probe prints a small number like 17. - It's the same depth as the tracer's LVL. thread_pid->level is 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. !Run A: every check reaches the host 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 What I observed in run A - Same namespaces as my shell. LVL was 0, and every namespace ID matched the baseline. - NSpid had 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, ~/.bashrc included. 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. nsprobe ignores arguments, so --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." - 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. !Run B: writes outside the project fail; the token and network still get out 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 The probe: == 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 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 nsprobe started id, grep, seven readlinks, ls, wc, four touches, rm, head and curl. Every one inherited the same namespaces. A sandbox applies to a process and everything it ever spawns. - ~/.bashrc failed with Read-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 / (needs CAP_SYS_ADMIN) or override permissions (needs CAP_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 /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. - 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-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 "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.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. --- Run C - Same Sandbox, Launched from ~ The policy didn't change. Only the directory I started from did. ![Run C: launched from ~, ~/.bashrc becomes writable] (dev-to-uploads.s3.us-east-2.amazonaws.com) The probe: == 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 What I observed in run C - Still sandboxed. New PID and mount namespaces (fresh IDs, since it's a new sandbox), NSpid of 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. - Every write succeeded, ~/.bashrc included. --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 ~/.ssh if it existed. The ~/.pi bind on top became redundant. - A writable ~/.bashrc is 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 $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. --- 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: !Run D: with the network cut, pi never reached its model and nothing ran Error: Connection error. Error: Connection error. Error: Connection error. Error: Connection error. Error: 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.53 lives 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.com but its curl may 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-run scope 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. --- 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-bind and --bind gave 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. --- References - bubblewrap - GitHub and its bwrap(1) man page - systemd-run(1) - bpftrace - pid_namespaces(7), proc_pid_status(5) for NSpid - mount_namespaces(7), network_namespaces(7) - Configure the sandboxed Bash tool - Claude Code docs - Isolation in Containers. Linux Primitives that help Achieve It
Until Claude Code 2.1.309, Your ~/.zshenv Ran Outside the Sandbox Before Each Command - Book: AI Agents Pocket Guide - Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go - My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools - Me: xgabriel.com | GitHub --- You turned on the sandbox. Every npm test and git status the agent ran was supposed to sit inside an OS-enforced box: writes limited to the project, network only through an allowlist. And then, before each of those commands, a few lines of your own shell config ran on the other side of the wall. That is the first bug fix in Claude Code 2.1.309, released October 22, 2026: "Fixed sandboxed Bash commands on macOS and Linux running the shell's startup files outside the sandbox before each command." One line in a list of 62 fixes. It is worth a closer look, because the reason it could happen at all is old shell behavior that most of us never think about. Any tool that wraps your shell in a sandbox runs into it. What the sandbox is supposed to cover The Claude Code Bash sandbox is off by default. You turn it on with /sandbox or sandbox.enabled: true. On macOS it uses Apple's Seatbelt framework. On Linux and WSL2 it uses bubblewrap. Writes are limited to the working directory, a per-user temp directory and folders you add. Network traffic goes through a local proxy that checks each host against your allowed domains, a list that starts empty. The docs also say the shell commands Claude runs "start inside its boundary, and so do the processes they start." Anything that runs before that boundary exists is outside it. Which code runs before your command is up to your shell. Your shell runs code you didn't type Claude Code runs Bash tool commands in bash or zsh. The environment variable docs say auto-detection uses your $SHELL when it points to one of those two. If your login shell is zsh, your agent's commands run in zsh. Here is the order zsh reads its files, from the zsh manual: 1. /etc/zshenv, which "cannot be overridden" 2. $ZDOTDIR/.zshenv (usually ~/.zshenv) 3. .zprofile, only for login shells 4. .zshrc, only for interactive shells 5. .zlogin, only for login shells Steps 1 and 2 have no condition. A throwaway zsh -c 'git status' is neither a login shell nor interactive, and it still reads ~/.zshenv. The manual says it plainly about the system file: it "is run for all instances of zsh". From the command line, the way out is zsh -f, which skips everything after /etc/zshenv. Bash has a different hook with the same effect. Per the bash manual, a non-interactive bash "looks for the variable BASH_ENV in the environment" and runs the file it names. If you, or a tool you installed, exported BASH_ENV, every bash -c runs that file first. sh is the quiet one. Bash invoked as sh reads $ENV only when it is interactive, so sh -c runs no startup file at all. !A shell process opening a door in a sandbox wall, with a small config file slipping through before the door shuts The shell reads its startup files the moment it starts. Whether that is inside the box depends on what started first. Which side of the wall the shell starts on Any sandbox that wraps a shell command has to pick an order. Here are the two shapes, with a made-up sandbox-run standing in for the real wrapper (Seatbelt or bubblewrap): # Shell first: zsh reads ~/.zshenv, THEN the # sandbox starts around the command. zsh -c 'sandbox-run -- npm test' # Sandbox first: the box exists before zsh # starts, so ~/.zshenv runs inside it. sandbox-run -- zsh -c 'npm test' In the first shape, ~/.zshenv runs with your full user rights: it can write anywhere you can and open network connections no proxy sees. Only npm test is confined. The changelog doesn't say how Claude Code's wrapper was built before 2.1.309, so I won't guess at its internals. What it does say is the outcome: on macOS and Linux, the startup files ran outside the sandbox, before each command. From 2.1.309, they no longer run outside it. How bad that was depends on what is in your files. For most people ~/.zshenv sets PATH and a couple of variables, and nothing about that is dangerous. It matters when the file does more: fetches a token from a CLI, calls a version manager that checks the network, writes a cache, or sources something from a folder you don't control. All of that ran unconfined, once per command, with the agent choosing when commands run. There is a related fix in 2.1.304 (October 19): unsandboxed commands in Claude Desktop's Code tab weren't seeing variables exported in startup files such as ~/.zshrc. Agents depend on those files to get a working PATH, which is why a wrapper can't just skip them. See it on your own machine Add one line to the top of ~/.zshenv. It appends a timestamp, the shell's PID and the first 50 characters of the -c string (zsh keeps it in ZSH_EXECUTION_STRING) to a log. Errors are thrown away so a failed write never breaks a command. # TEMPORARY trace, delete when done { print -r -- "$(date +%T) $$ ${ZSH_EXECUTION_STRING[1,50]}" \ >> ~/zshenv-trace.log } 2>/dev/null Check that it fires for a non-interactive shell, and that -f skips it: zsh -c 'true' zsh -f -c 'true' cat ~/zshenv-trace.log You should see one new line, from the first command. Now start a Claude Code session, ask it to run your tests, look around the repo and check git status. Then count: wc -l ~/zshenv-trace.log Each line is one time your ~/.zshenv ran. The number depends on how many commands the agent ran and what Claude Code starts for itself, so I'm not going to give you one. Run it and see what you get. One thing to read in the result: the sandbox only allows writes to the project, the temp directory and folders you add, so a shell started inside it can't append to a file in your home folder. On 2.1.309 or later with the sandbox on, sandboxed commands can't write to your home folder (unless you added it as a write path), so their lines won't show up in the log. That gap is a sign the file no longer runs outside the box. Any line that does appear came from a shell started outside the sandbox. For bash, the same check uses BASH_ENV: printf 'echo "BASH_ENV ran in $$" >&2\n' > /tmp/env-demo BASH_ENV=/tmp/env-demo bash -c 'echo hi' ENV=/tmp/env-demo sh -c 'echo hi' The bash line runs the file before echo hi. The sh line doesn't, because $ENV is for interactive shells only. Delete the trace line from ~/.zshenv when you are done. Audit what your startup files actually do Updating closes this hole. It doesn't change the fact that these files run for every agent command, inside the box or not. So read them: grep -nE 'curl|wget|eval|source|TOKEN|KEY|SECRET' \ ~/.zshenv ~/.zprofile ~/.zshrc ~/.bashrc 2>/dev/null What to look for, and where it belongs: - Secrets exported as variables. A token exported in ~/.zshenv is re-exported by every shell the agent starts, deny list or not. Move it to ~/.zshrc, or out of your shell config entirely. The sandbox docs also say sandboxed commands inherit Claude Code's environment, "including any secrets in its environment." For those, deny the ones an agent doesn't need with sandbox.credentials.envVars and "mode": "deny", which unsets them before each sandboxed command. - Network calls. A curl, a plugin manager update check, or a gh auth token lookup has no reason to run for every non-interactive shell. Move it to ~/.zshrc, which zsh -c doesn't read. - eval "$(some-tool init)" lines. These run a program and execute whatever it prints. If an agent command doesn't need the tool, move the line to ~/.zshrc. - PATH and plain exports. These are what ~/.zshenv is for. Keep them there. Then update. claude --version tells you where you are, and claude update gets you current. If you're below 2.1.309, you don't have the fix. Where Hermes fits I build Hermes IDE, so weigh this with that in mind. Hermes runs Claude Code, Codex and other agents each in a real terminal, running the agent's own interface. The sandbox in this post is Claude Code's, configured in Claude Code's settings, and it works the same in Hermes as in any terminal. Where it helps here is small. The ⌘N agent picker shows which agents are installed and the version of each, so an old Claude Code is easy to spot before you start a task with it. !The New task sheet's agent picker listing Claude Code as installed with its version number, Codex installed, and other agents marked Not installed The ⌘N New task sheet with its agent picker open: Claude Code (installed, 2.1.299) selected, Codex installed (0.145.0), and the other supported agents marked Not installed. That build predates the fix, which is the point of checking. Hermes is source-available under BSL 1.1 and runs on macOS, Windows and Linux. --- If this was useful Any agent harness can hit the same gap: the guardrail wraps the command, and the setup around it runs with your full rights. My AI Agents Pocket Guide covers where those boundaries sit in an agent loop, and how to check what really runs inside them. You can download Hermes IDE or read the source on GitHub. 
No Docker Desktop? WSL2 + Podman on Windows: setup and the errors I hit Two ways to run containers on Windows without Docker Desktop, plus every red error message I met on the way. Here is the situation. Your laptop is locked down, Docker Desktop is not on the approved list (or the license is a "talk to finance" thing), and you still need to run docker build before lunch. Good news: you do not need Docker Desktop. Bad news: the two replacements each have their own little traps, and one of them quietly hands you root. TL;DR - Option 1: WSL2 + Docker Engine. Install Ubuntu in WSL, apt install docker.io, done. Feels exactly like Docker. Watch out for systemd and the docker group. - Option 2: Podman. No daemon, rootless by default, same commands (alias docker=podman). Watch out for low ports and the Docker named pipe. - Security gotcha: "fixing sudo" by joining the docker group gives you root-level power. Rootless Podman and real secrets beat that. !Two ways to run containers on Windows without Docker Desktop Table of contents - [1. Pick your path](#1-pick-your-path) - [2. Option 1 WSL2 and Docker Engine](#2-option-1-wsl2-and-docker-engine) - [3. Option 2 Podman on Windows](#3-option-2-podman-on-windows) - [4. The errors I hit and how to fix them](#4-the-errors-i-hit-and-how-to-fix-them) - [5. Security gotcha](#5-security-gotcha) - [6. Checklist](#6-checklist) - [Sources](#sources) 1. Pick your path Both options use WSL2 under the hood. The difference is who runs the containers. | | WSL2 + Docker Engine | Podman | |---|---|---| | Where it runs | Docker daemon inside your Ubuntu distro | A small Podman machine (a WSL2 distro) | | Daemon | Yes, dockerd running as root | No central daemon | | Default user | root daemon, you call it via sudo or the docker group | Rootless | | Commands | docker ... | podman ... (almost the same) | | You run it from | The Ubuntu shell | PowerShell, CMD or WSL | | Best for | "I want my Docker workflow back" | Locked-down or security-minded setups | My rule of thumb: if your team's scripts are full of docker compose and Docker-only flags, go with Option 1. If you mostly build and run images and your security team frowns at root daemons, go with Podman. 2. Option 1 WSL2 and Docker Engine This keeps your workflow almost identical to Docker Desktop. You just do the plumbing yourself. Step 1. Install WSL. Open PowerShell as administrator: wsl --install Restart the machine. You need Windows 10 version 2004 (build 19041) or newer, or Windows 11. By default this installs Ubuntu and sets new installs to WSL 2. Check it: wsl -l -v You want to see VERSION 2 next to your distro. Step 2. Install (or pick) a distro. Ubuntu is the default, but you can ask for it by name, and list the others: wsl --install -d Ubuntu wsl --list --online Step 3. Start Ubuntu. wsl -d Ubuntu Step 4. Install Docker Engine inside WSL (no Desktop): sudo apt update sudo apt install -y docker.io sudo systemctl enable docker sudo systemctl start docker Step 5. Test. sudo docker run hello-world If you see "Hello from Docker!", you have a working engine. Notice the sudo though. We will come back to that, because that is where people (me included) take a shortcut with a security price tag. Pros: same commands, same Dockerfiles, and an easy path to Minikube later. Cons: you manage the engine yourself, and it is a little less smooth than Desktop. Source: Install WSL 3. Option 2 Podman on Windows Podman is daemonless: there is no big root process waiting for orders. On Windows, Podman runs inside a "Podman machine", which is a WSL2 distro it manages for you. The podman command in PowerShell talks to it. Step 1. Install Podman. The official way is the Windows installer from the Podman GitHub releases page. The quick way is winget: winget install RedHat.Podman Close and reopen the terminal so podman.exe is on your PATH. Step 2. Create and start the Podman machine. podman machine init podman machine start If WSL is not installed yet, podman machine init offers to install it for you, reboots, and carries on after you log in. Nice, but do not be surprised by the reboot. Step 3. Test. podman run hello-world If a short image name like hello-world makes Podman ask which registry you mean, give it the full name: docker.io/library/hello-world. Step 4. Use your Docker muscle memory. podman build -t myapp . podman run myapp alias docker=podman # in a bash shell In PowerShell the equivalent is Set-Alias docker podman (add it to your profile to keep it). Pros: no daemon, rootless by default, Docker-compatible CLI, and security teams like it. Cons: some edge cases with networking and volumes, and Kubernetes integration is a bit more work. Source: Podman installation, Podman for Windows guide 4. The errors I hit and how to fix them The fun part. Here is what broke, why, and the fix. Error 1. wsl --install just prints the help text. Why: WSL is already installed, so the "install everything" path does not run. Fix: list what is available and install a distro by name: wsl --list --online wsl --install -d Ubuntu If the download hangs at 0.0%, add --web-download: wsl --install --web-download -d Ubuntu. Error 2. System has not been booted with systemd as init system (PID 1). Can't operate. Why: systemctl needs systemd, and some distros or older WSL builds still use the old WSL init. The current Ubuntu from wsl --install has systemd on by default, so you mostly hit this with other distros. Fix: check your WSL version is 0.67.6 or newer, then add this to /etc/wsl.conf inside the distro: [boot] systemd=true Then restart WSL (see Error 4). Confirm with systemctl status. Error 3. permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock Why: the Docker socket belongs to root and the docker group. Your user is in neither, so plain docker ps gets the door slammed. Fix: add yourself to the group, then restart WSL so the new group sticks: sudo usermod -aG docker $USER wsl --shutdown wsl -d Ubuntu Then docker ps works without sudo. Here is my lab after the fix: empty table, no error, no sudo. !docker ps works without sudo after usermod and wsl --shutdown Before you celebrate, read section 5. This fix has a catch. Error 4. I changed a setting and nothing happened. Why: WSL only picks up wsl.conf changes after the distro fully stops. Microsoft calls it the "8 second rule": close every shell, then wait about 8 seconds. Fix: wsl --list --running should say there are no running distributions. Or use the big hammer: wsl --shutdown. It stops all distros, including your Podman machine, so save your work first. Error 5. Rootless Podman will not bind port 80. Why: rootless containers cannot bind ports below 1024. Fix: map to a higher host port (-p 8080:80), which is what I do on a dev laptop anyway. Only if you really need port 80, stop the machine and switch it to rootful: podman machine stop podman machine set --rootful podman machine start podman machine set --rootful=false takes you back. Error 6. Docker tools talk to the wrong engine. Why: Podman listens on the same named pipe Docker clients use by default, npipe:////./pipe/docker_engine. If something else already holds that pipe, the two fight. Fix: point your tools at Podman's own pipe by setting DOCKER_HOST to npipe:////./pipe/podman-machine-default. Error 7. podman machine init fails after the automatic WSL install. Why: the auto-install sometimes gets stuck halfway. Fix: install WSL manually with wsl --install from an admin PowerShell, reboot, and run podman machine init again. Also check that virtualization is enabled: WSL needs it, and a VM host needs nested virtualization. Source: WSL config, 8 second rule, systemd in WSL 5. Security gotcha This is the DevSecOps bit. Read it before you copy any of the fixes above to a shared machine. 1. The docker group is root with extra steps. Docker's own docs say it plainly: the docker group grants root-level privileges. Anyone in it can start a container that mounts / from the host and edit anything. On your personal lab laptop, fine. On a shared build box, think twice before running usermod -aG docker for everyone. 2. Rootless is the safer default, so keep it. Podman machines start rootless. --rootful exists, but switching to it just to get port 80 trades a small annoyance for a bigger blast radius. Map to 8080 instead. 3. Your Windows drives are inside WSL. By default WSL mounts C:\ at /mnt/c. A container started with -v /mnt/c:/host can read your whole user profile, browser data included. Mount only the project folder you need. 4. Secrets in compose files end up in Git. environment: DB_PASSWORD=SuperSecret123 in a compose file is how passwords travel to a public repo. Keep secrets out of the file: use a .env file listed in .gitignore, or Podman secrets, which are mounted as a file and never baked into an image: printf 'my-lab-password' | podman secret create db_password - podman run --secret db_password docker.io/library/alpine cat /run/secrets/db_password 5. License is a security-adjacent problem too. Docker Desktop is free for personal use and small businesses (under 250 employees and under $10M revenue). Bigger companies need a paid plan. Unlicensed tools on corporate laptops tend to get noticed by the same people who run your security reviews. Source: Docker post-install steps (docker group warning), podman secret create 6. Checklist - [ ] wsl -l -v shows my distro on VERSION 2 - [ ] systemctl status works inside Ubuntu (systemd on) - [ ] docker run hello-world or podman run hello-world prints its hello - [ ] I know the docker group equals root, and I only use it on my own lab machine - [ ] My Podman machine is still rootless, and I map ports above 1023 - [ ] I mount project folders, not the whole /mnt/c - [ ] No passwords in compose files: .env is in .gitignore or I use podman secret - [ ] I run wsl --shutdown knowing it stops every distro, Podman included Which did you pick on your Windows machine, WSL2 + Docker or Podman? And what was your first red error? Tell me below. Sources - Microsoft Learn: Install WSL - Microsoft Learn: Use systemd to manage Linux services with WSL - Microsoft Learn: Advanced settings configuration in WSL - Podman docs: Installation - Podman for Windows guide - Podman docs: podman machine set - Podman docs: podman secret create - Docker docs: Linux post-installation steps - Docker docs: Docker Desktop license agreement
skopeo ⭐ 11k Skopeo is a command-line tool for developers and operators to copy, inspect, and manage container images across registries. 🔗 containers/skopeo #opensource t.co
Claude Code's Sandbox Is an npm Package. Run srt Yourself and Watch What It Blocks - Book: AI That Acts - The series: AI in TypeScript — 5 books, from your first LLM call to agents in production — all five here - My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools - Me: xgabriel.com | GitHub --- Turn on Claude Code's sandbox and ask Claude to run cat ~/.ssh/known_hosts. The command goes through. That isn't a bug. Claude Code's sandboxing docs say a sandboxed command can read "most of the machine, including credential files such as ~/.ssh and ~/.aws/credentials" until you deny them. The same page has a line most people scroll past: "Claude Code builds the sandbox on the open source @anthropic-ai/sandbox-runtime package." That package ships a CLI called srt. You can run it without Claude Code, wrap any command in it, and see for yourself which layer refuses what. It's a fast way to learn what /sandbox promises before you let an agent run with fewer prompts. @anthropic-ai/sandbox-runtime is Apache-2.0, and its README calls it a "Beta Research Preview" whose "APIs and configuration formats may evolve." The npm registry lists version 0.0.80, which needs Node.js 22.12.0 or newer. There is no Docker image and no VM. On macOS it runs your command through sandbox-exec with a Seatbelt profile it generates. On Linux it uses bubblewrap. Windows support exists and the README marks it alpha. The network goes through two proxies, an HTTP proxy and a SOCKS5 proxy, that run on the host outside the sandbox and check every host against your lists. The sandboxed process has no other way out: on Linux "the network namespace of the sandboxed process is removed entirely," and on macOS "the Seatbelt profile allows communication only to a specific localhost port." Anthropic introduced it in Beyond permission prompts on October 20, 2025. The post says the limits cover "any scripts, programs, or subprocesses that are spawned by the command," and that "in our internal usage" sandboxing cut permission prompts by 84%. That figure is Anthropic's own measurement, not an independent one. Install it and write a policy npm install -g @anthropic-ai/sandbox-runtime # macOS: srt looks for ripgrep on your PATH brew install ripgrep # Debian and Ubuntu sudo apt-get install bubblewrap socat ripgrep ripgrep is how srt finds the paths it must deny, and socat relays traffic to the proxies on Linux. Install srt globally, not into your project. The README's security notes give the reason: a copy in <project>/node_modules/ with the project writable "can be rewritten by a sandboxed command, and the next srt there runs the result unsandboxed." On Ubuntu 24.04 and later, AppArmor stops bubblewrap from creating user namespaces by default, and the Claude Code docs have the profile that allows it. With no settings file, srt starts anyway, with no network and writes confined to a few runtime paths such as /tmp/claude. Claude Code's sandbox environments page puts it plainly: "Don't take a clean start as proof your settings loaded." Save a policy as ~/.srt-settings.json: { "network": { "allowedDomains": ["registry.npmjs.org"], "deniedDomains": [] }, "filesystem": { "denyRead": ["~/.ssh"], "allowWrite": ["."], "denyWrite": [] } } The config schema requires all five lists, even the empty ones. If the file exists but is empty or invalid, srt refuses to start, which is what you want from a security tool. Reads are allowed everywhere unless you deny them. Writes are denied everywhere unless you allow them. Network is denied unless the host is on allowedDomains, and deniedDomains is checked first. One probe, run twice Make a throwaway git repo, so there is a .git/hooks folder to aim at, and save this in it as probe.sh: #!/usr/bin/env bash # probe.sh: run it plain first, then under srt check() { if "$@" >/dev/null 2>&1; then echo "ok $*" else echo "FAIL $*"; fi } check cat "$HOME/.ssh/known_hosts" check touch "$HOME/srt-probe" check touch ./srt-probe check touch .git/hooks/srt-probe check curl -fsS https://registry.npmjs.org/left-pad check curl -fsS https://example.com check curl -fsS --noproxy '*' https://registry.npmjs.org/left-pad mkdir -p ~/code/srt-lab && cd ~/code/srt-lab && git init # save probe.sh here, then: bash probe.sh srt bash probe.sh rm -f ~/srt-probe ./srt-probe .git/hooks/srt-probe The plain run is your baseline. Every line should say ok, unless you have no known_hosts file. In the second run, srt starts bash, and every cat, touch and curl that bash starts inherits the same limits. Pass the command as separate words, as above. In the current CLI source, positional arguments are shell-quoted before srt runs them with bash -c, so srt "bash probe.sh" would look for a program literally named bash probe.sh. For a line with pipes, redirects or ~, use srt -c '...', which the source describes as "run command string directly (like sh -c), no escaping applied." Here is what each line should do under srt, going by the README and Claude Code's docs. I'm working from those, not from a run of my own, so trust your terminal over this table: | Probe | Under srt | What stops it | |---|---|---| | cat ~/.ssh/known_hosts | FAIL | denyRead, enforced by the OS | | touch ~/srt-probe | FAIL | Writes are allow-only, home isn't listed | | touch ./srt-probe | ok | allowWrite: ["."] | | touch .git/hooks/srt-probe | FAIL | Built-in deny, even inside . | | curl registry.npmjs.org | ok | Host is allowed, proxy passes it | | curl example.com | FAIL | Proxy refuses the host | | curl --noproxy '*' | FAIL | OS layer: no route but the proxy | The README shows a denied read failing with Operation not permitted. For a write outside the boundary, Claude Code's docs list Operation not permitted on macOS and Read-only file system on Linux and WSL2, and they show a --noproxy curl failing with Could not resolve host. A refused HTTP request gets a 403 from the proxy with the header X-Proxy-Error: blocked-by-sandbox-runtime, per the README. Two layers, and the gap between them The last two curl lines are the whole design. The proxy decides by hostname. Claude Code's docs say it "checks the hostname of each connection against your allowed and denied domains" and, by default, "does not terminate or inspect TLS." They also warn that broad entries such as github.com "can create paths for data exfiltration," and the README adds that domain fronting may get past the filter. Treat every allowed domain as a place your data can go. The OS layer is why ignoring the proxy doesn't help. The proxy reaches tools through HTTP_PROXY, HTTPS_PROXY and ALL_PROXY. A tool that ignores them has no route at all, which is also why, per Claude Code's docs, plain ssh and most database drivers "can't connect, even to an allowed host," and UDP, QUIC and ping can't leave either. The .git/hooks line shows the other built-in rule. Some paths are write-denied even inside an allowed folder, among them shell startup files, .gitconfig, .git/hooks, .git/config, .vscode, .idea, .mcp.json, .claude/commands and .claude/agents. Those are the files where a sandboxed command could plant code that something unsandboxed runs later. One caveat from the sandbox environments page: on Linux the runtime builds that deny list once at launch, so it doesn't cover a repo the session creates later with git init or git clone. Inside Claude Code, /sandbox wraps shell commands only. The docs list what runs outside it: the Read, Edit, WebFetch and WebSearch tools, hooks, local MCP servers and LSP servers. "A denyRead entry doesn't stop the Read tool." The sandbox is also off by default, and if a dependency is missing, Claude Code runs commands unsandboxed unless you tell it to stop. Put this in ~/.claude/settings.json to close those doors: { "sandbox": { "enabled": true, "failIfUnavailable": true, "allowUnsandboxedCommands": false, "filesystem": { "denyRead": ["~/.ssh", "~/.aws"] } } } allowUnsandboxedCommands: false turns off the retry where Claude asks to rerun a failed command outside the sandbox. For the Read tool you still need a deny rule in permissions. To put hooks and MCP servers behind the same wall, the docs suggest running all of Claude Code under srt with npx @anthropic-ai/sandbox-runtime claude. The sandbox environments page lists the paths and domains your srt settings must allow first. Put srt around your own agent's tool calls srt is also a library. If you build an agent in TypeScript and give the model a shell tool, wrap the tool's handler. This follows the README's library example: // sandboxed-shell.mts import { spawn } from 'node:child_process' import { SandboxManager, type SandboxRuntimeConfig, } from '@anthropic-ai/sandbox-runtime' const config: SandboxRuntimeConfig = { network: { allowedDomains: ['registry.npmjs.org'], deniedDomains: [], }, filesystem: { denyRead: ['~/.ssh'], allowWrite: ['.'], denyWrite: [], }, } await SandboxManager.initialize(config) initialize() starts the proxies once, at startup. The tool handler goes in the same file: // Handler for a "run_shell" tool the model can call export async function runShell(cmd: string): Promise<string> { const wrapped = await SandboxManager.wrapWithSandbox(cmd) return new Promise((resolve) => { const child = spawn(wrapped, { shell: true }) let out = '' child.stdout.on('data', (d) => (out += d)) child.stderr.on('data', (d) => (out += d)) child.on('close', (code) => resolve(`exit ${code}\n${out}`)) }) } // On shutdown: await SandboxManager.reset() wrapWithSandbox() returns the command line that runs your string inside the boundary. The model gets Operation not permitted back as the tool result and can change course, and nobody has to click Approve. Run the agent from a workspace folder that isn't the folder holding your app and its node_modules, for the same reason the README gives about installing srt. Where Hermes IDE fits I build Hermes IDE, so weigh this section with that in mind. By default, Hermes opens each agent in a real terminal running the agent's own interface. /sandbox is Claude Code's own command, so in that terminal it opens the same panel and enforces the same boundary. Hermes also has an opt-in Agent view for Claude that renders the conversation in place of Claude's terminal interface, and this post doesn't cover that mode. Codex brings its own sandbox, and OpenAI's sandboxing page says it uses Seatbelt on macOS and bubblewrap on Linux and WSL2 too. !Codex and Claude Code sessions side by side in Hermes IDE, each in its own hermes/ worktree, with the sessions sidebar on the left Two real agent sessions side by side: Codex on orders-types and Claude Code on orders-api, each in its own hermes/ worktree, with the sessions sidebar (status, context use, cost, branch) on the left. Hermes adds no sandbox of its own. A worktree per task keeps agents out of each other's files, and your home directory is still the sandbox's job. Claude Code's sandbox does know about worktrees: in a linked worktree it also allows writes to the main repository's shared .git, so git commit works, while hooks/ and config in there stay write-denied. When the sandbox needs a decision, such as a new domain in manual or accept-edits mode, or an unsandboxed retry, the prompt appears in Claude Code's terminal. For an agent blocked on a prompt like that, Hermes shows the session as needing approval (and says when that status is a guess), the attention inbox counts the agents blocked on you, and ⌘I (Ctrl+Shift+I on Windows and Linux) jumps to the agent that has waited longest. You answer in that agent's own terminal. Hermes never answers a permission prompt for you and never types into a terminal on its own. !The Hermes IDE New task sheet with a refund-endpoint task typed, chips for Claude Code, the project, a new worktree, Accept edits, model and effort, and a preview of the claude command Hermes will run The ⌘N New task sheet: Claude Code, the orders-api project, a new hermes/ worktree, Accept edits, model and effort, and the "Hermes will run" preview of the claude command. Hermes also runs any command-line agent you add, so npx @anthropic-ai/sandbox-runtime claude can be the command, once your srt settings allow what Claude Code needs. I haven't run that combination myself. --- If this was useful The runShell handler above is the moment an agent stops talking and starts acting, and that's what AI That Acts is about: tool calling, functions and a first working agent, in TypeScript. Read it for the tool loop, then put srt around the tool that touches your machine.  It's book 3 of AI in TypeScript, five books from your first LLM call to agents in production: all five here. Hermes IDE is free, source-available under BSL 1.1, and runs on macOS, Windows and Linux. Download it or star it on GitHub. It isn't affiliated with Anthropic, OpenAI, Nous Research or any other agent vendor.
I Replaced runc with a Rust Container Runtime (youki) for a Week The Boring Layer That Matters Most Every container you've ever run — docker run hello-world included — went through a tiny binary called runc before it became a process. It creates the namespaces, sets up the cgroups, drops privileges, and hands off to your entrypoint. You never think about it. And that's the problem. runc is written in Go + C (yes, C — for the nsenter and pivot_root bits that Go can't handle cleanly). It's been the reference implementation since 2014. It's also the layer where CVE-2024-21626 (a container escape) lived. What if that layer was memory-safe by construction? Enter youki — a container runtime written entirely in Rust, now a CNCF Sandbox project. --- What Is youki? youki (Japanese for "container") is an OCI runtime-spec implementation in Rust. It's a drop-in replacement for runc: same CLI, same spec, same integration path through containerd, Podman, or Docker. | Fact | Detail | |---|---| | Language | Rust (pure, except a libseccomp-rs FFI shim being replaced) | | CNCF status | Sandbox (accepted Oct 17, 2024) | | GitHub | youki-dev/youki | | Stars | ~7,300 | | License | Apache 2.0 | | Adopters | Azure Kubernetes Service, Docker, containerd/runwasi | | Contributing orgs | Microsoft, Docker, Red Hat | The key differentiator vs. runc: no Go runtime startup, no C nsenter binary, no mixed-language memory safety boundary. The entire create → start → delete lifecycle lives in one Rust binary with compile-time safety guarantees. --- The Benchmarks (Honest Version) The headline number from youki's own repo: Runtime Time (mean ± σ) Range (min … max) vs youki youki 111.5 ms ± 11.6 ms 84.0 ms … 142.5 ms 100% runc 224.6 ms ± 12.0 ms 190.5 ms … 255.4 ms 200% crun 47.3 ms ± 2.8 ms 42.4 ms … 56.2 ms 42% Youki is ~2× faster than runc for create → start → delete. But crun is ~2.5× faster than youki. Here's the honest context: | Scenario | Does it matter? | |---|---| | Single long-lived container | No. 100ms of startup is noise over a 7-day uptime. | | Serverless: 10k containers/min | Yes. A 100ms savings × 10k = 1,000 CPU-seconds per minute reclaimed. | | K8s pod churn (CI/CD, spot nodes) | Moderately. Image pull + CNI + scheduling dominate; runtime is ~5–10% of total. | | Edge/embedded (tight RAM) | Yes. youki's binary is smaller and RSS is lower per instance. | The real win isn't raw speed — it's the Rust safety guarantee. You're not betting your node's security on a C memcpy in nsenter. --- Setup: Replacing runc on a K8s Node Prerequisites - Kubernetes 1.28+ with containerd (not Docker Engine) - Linux kernel 5.10+ (youki drops older kernel support to use modern features) - cargo / just for building from source (or use a pre-built binary) Step 1: Build youki git clone https://github.com/youki-dev/youki.git cd youki just build # Binary: ./target/release/youki Or grab a release binary from GitHub Releases. Step 2: Register with containerd Edit /etc/containerd/config.toml (or use containerd config path): [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.youki] runtimeType = "io.containerd.youki.v1" Then install the shim (youki ships a containerd-compatible shim): sudo cp target/release/youki /usr/local/bin/ sudo cp target/release/containerd-shim-youki-v1 /usr/local/bin/ Restart containerd: sudo systemctl restart containerd Step 3: Create a RuntimeClass apiVersion: node.k8s.io/v1 kind: RuntimeClass metadata: name: youki handler: youki scheduling: nodeSelector: kubernetes.io/arch: amd64 kubectl apply -f runtimeclass-youki.yaml Step 4: Run a Pod with youki apiVersion: v1 kind: Pod metadata: name: youki-test spec: runtimeClassName: youki containers: - name: app image: nginx:alpine resources: requests: memory: "32Mi" cpu: "10m" kubectl apply -f youki-pod.yaml kubectl get pod youki-test -o wide # youki-test 1/1 Running 0 2s Verify it's actually using youki: kubectl exec youki-test -- cat /proc/1/cgroup # Confirms cgroup v2 hierarchy managed by youki --- Step 5: Run It for a Week (What I Noticed) I ran a 3-node k3s cluster with a mix of workloads: - 20 long-lived API pods (Go, ~50 MB each) - A CI pipeline spawning 50–200 short-lived pods/day - A cron job that scales a batch processor 0 → 10 → 0 every hour What went well: 1. Zero crashes, zero OOMs in the runtime itself. The Rust error handling is visible in logs — when something goes wrong, you get a structured Error with context, not a segfault. 2. Short-lived pods were noticeably snappier. My CI pods (build → test → delete) came up ~30–50ms faster than runc. Not dramatic per-pod, but across 200 pods/day it added up. 3. Rootless mode was cleaner. youki create with --rootless worked without the newuidmap/subuid gymnastics that runc sometimes needs. 4. Binary size. youki: ~8 MB. runc: ~12 MB. crun: ~3 MB. On an edge node with 512 MB RAM, every MB counts. What was rough: 1. cgroups v1 support is there but v2 is the path of least resistance. If your distro still defaults to v1 (looking at you, CentOS 7), you'll hit edge cases. 2. No --checkpoint/--restore (CRIU) yet. If you rely on live migration or pause/resume, you're stuck on runc. 3. Docs are thin. The user docs cover the basics but there's no "migrating a production cluster from runc to youki" guide. You're on your own. 4. One C dependency remains. libseccomp-rs is an FFI wrapper around the C libseccomp. The team is working on a pure-Rust replacement, but it's not done. --- youki vs. runc vs. crun: Where Each Wins | Dimension | runc | crun | youki | |---|---|---|---| | Language | Go + C | C | Rust | | Memory safety | ❌ (C portion) | ❌ | ✅ (99% — one FFI shim) | | Create→delete (benchmark) | 224 ms | 47 ms | 111 ms | | Binary size | ~12 MB | ~3 MB | ~8 MB | | cgroups v2 | ✅ | ✅ | ✅ | | Rootless | ✅ | ✅ | ✅ (cleanest) | | CRIU (checkpoint/restore) | ✅ | ✅ | ❌ (roadmap) | | Wasm support | ❌ | ❌ | ✅ (first-class) | | CNCF governance | ❌ (OCI) | ✅ (Sandbox) | ✅ (Sandbox) | | Production battle-testing | 10+ years | 5+ years | ~3 years | | Best for | Default, compatibility | Max performance, minimal footprint | Safety + Wasm + future-proofing | The honest takeaway: If you need maximum raw speed today, crun wins. If you need the safest runtime with a path to Wasm workloads, youki is the pick. runc remains the "it just works" default. --- The Wasm Angle (Why This Matters More Than You Think) Here's the part that excites me: youki is the low-level runtime that powers runwasi (the containerd shim for Wasm workloads) and, by extension, SpinKube. The architecture: containerd └── containerd-shim-runwasi (Rust) └── youki libcontainer (Rust) ← shared library └── Wasmtime / Wasmer / WasmEdge Youki's libcontainer crate is used as a library by other Rust container tools. That means the same memory-safe, OCI-compliant primitives that run your nginx:alpine pod also run your Wasm modules. One codebase, two workload types, same security guarantees. This is the "Rust container stack" taking shape: | Layer | Rust project | Role | |---|---|---| | Runtime | youki | OCI container lifecycle | | Shim | runwasi | Wasm on containerd | | Orchestrator | SpinKube | Wasm on Kubernetes | | Image tooling | naftiko | Build OCI/Wasm images | | Spec | oci-spec (Rust crate) | The spec itself, in Rust | Youki is the foundation. If it's not solid, nothing above it is. --- What's Missing (and Where Funding Helps) youki is in CNCF Sandbox — the earliest maturity level. The project is healthy (active commits, 80+ contributors, backing from Microsoft/Docker/Red Hat) but under-resourced for what it needs to do: - Pure-Rust seccomp (eliminate the last C dependency) — significant engineering effort - SELinux in Rust — needed for RHEL/CentOS production adoption - CRIU support — checkpoint/restore is table-stakes for production - OCI conformance test suite — a dedicated, automated compliance harness - Docs and migration guides — "how do I move a 500-node cluster from runc to youki?" - Security audit — a formal audit would be a major trust signal for enterprise adoption - Conference presence — KubeCon, RustConf, containersc — the project needs more visibility No LFX Crowdfunding campaign is active yet. If you use or evaluate youki, the highest-impact things you can do: 1. Contribute — even docs, test cases, or bug reports. GitHub 2. Run it in production and report what breaks (or doesn't) 3. Write about it — this article is a start, but we need more "I ran youki in a real cluster" content 4. Watch for an LFX Crowdfunding launch — CNCF funding page --- TL;DR | Question | Answer | |---|---| | Is youki production-ready? | For new, non-critical workloads: yes. For a 500-node cluster: not yet. | | Is it faster than runc? | ~2× for create/delete. Yes, but crun is faster still. | | Is it safer than runc? | Yes — memory-safe Rust eliminates the C nsenter attack surface. | | Should I switch today? | If you're running Wasm workloads or want to be early on the Rust container stack: yes. Otherwise: watch this space. | | What's the risk? | It's a Sandbox project. The API could shift. CRIU is missing. Docs are thin. | --- Final Thought The container runtime is the most underappreciated layer in the stack. It's the thing that's always running, that touches every syscall, that sits between your workload and the kernel. For a decade, that layer was a Go binary with a C sidecar. Now there's a Rust option with CNCF governance, major-company backing, and a clear path to being the foundation for the next generation of container workloads (including Wasm). I ran it for a week. It didn't break. It was faster. And the Rust error messages in the logs were genuinely pleasant to read. That's about as good as "boring infrastructure" gets. --- Have you tried youki in production? What broke? What didn't? Drop your experience in the comments — I'm especially curious about anyone running it alongside runwasi or SpinKube. If you want to support the project, the best things right now are contributing code/docs and writing about your experience. The CNCF funding infrastructure is ready when the project is.
And Linux would like to present you bubblewrap [1][2][3][4] or the infamous SELinux. [1]: containers/bubblewrap [2]: man.archlinux.org [3]: wiki.archlinux.org [4]: wiki.archlinux.org
Today I learnt that until recently @Podman_io didn't allow multiple env files as arguments like Docker, however this functionality has recently been added. podman-compose#1499 #Docker #Podman
If it's their own computer, what better way for them to learn than by making mistakes? And if it's a shared computer, Linux container tools (e.g. containers/toolbox) would let them "sudo rm -rf /" in their own environment without jeopardizing the base system or other users.