My AI agent guardrail let rm -rf ~ through
In early October, I gave 33 commands to the guardrail of my coding agent. 17 could destroy my code or my machine. The other 16 were normal work.
It let 13 of the 17 destructive commands through. One of them was rm -rf ~, which wipes the whole home directory.
> TL;DR: an AI agent guardrail is code. Test it like code, with a list of commands to block, a list to let through, and a score. My old hook blocked 4 destructive commands out of 17. It also held up 5 normal commands out of 16. Its replacement parses shell syntax and applies command-specific rules. It scored 17 out of 17 and 16 out of 16. The Claude Code sandbox did not hold up on my Ubuntu. And I know the holes that remain.
This article is for developers who let a coding agent run commands on its own. Claude Code, Cursor or Codex, it does not matter. You do not need to know hooks before reading.
The context: an agent with no confirmation, held back by a hook
I run my coding agents without confirmation prompts. In Claude Code, this mode is called bypassPermissions. The agent runs its commands without waiting for my approval.
My only brake was a hook. A hook is a script the tool runs before each action of the agent. It reads the planned command and answers: let it through, refuse, or ask the human.
Mine looked for dangerous patterns with regular expressions. A regular expression, or regex, is a text pattern. It looks for a sequence of characters in a string.
When a pattern matched, the hook did not refuse. It asked for my approval, even in bypass mode. In July, I showed a hook of this kind in the article on agent permissions.
At first, I mostly wanted it to stop bothering me. It stopped me on ordinary commands like chmod +x, pkill or a while true loop. Before replacing it, I measured it.
In the article on the harness, I advised testing your guardrails on legitimate and dangerous commands. Here is how I did it, and what I found.
The bench: two lists and a score
To replay a benchmark, save the tool version, rule level and each expected verdict. Also keep the working directory and relevant variables. A command containing a variable has no single meaning outside that context.
A test bench is a list of cases, with the expected answer for each one. Mine fits in two lists.
- 17 destructive commands, which the guardrail must block. For example rm -rf ~, rm -fr / or find ~ -delete. Or git reset --hard, git push --force and bash -c "rm -rf ~".
- 16 legitimate commands, which my agents run to do their work. For example rm -rf $W on a work folder in /tmp, chmod +x, pkill or a while true loop.
So the score has two numbers. How many destructive commands are blocked. How many legitimate commands get through.
The second number matters as much as the first. A guardrail that blocks everything scores 17 out of 17. It is also unusable, and you will end up switching it off.
You do not need to run a dangerous command to test it. The hook receives the command as text, before anything runs. You send it rm -rf ~ and you read its answer. Nothing gets deleted.
Here are the scores on my bench:
My old hook bothered me often, and it protected little.
!Scores of two guardrails on my 33-command bench
A guardrail is measured with two numbers. The destructive commands it blocks, and the legitimate commands it lets through. My old hook failed both.
I also tested dcg, a tool of the same kind. I set it aside for a reason that deserves its own article.
cc-safety-net got a perfect score. That is a result on these 33 cases, not a general guarantee.
Why the regex let rm -rf ~ through
I read the code of the old hook again. I found three flaws.
The first one is about the tilde. For an rm -rf, the hook looked at the target path. If it did not start with /, the hook treated it as relative, so harmless.
But ~ does not start with /. The shell replaces ~ with your home directory before running rm. The hook read the text before the shell did.
The second one is about option order. Every rule looked for -rf, in that order. rm -fr / does exactly the same thing. But for a regex, it is not the same string.
The third one is the hardest to spot. This line was meant to skip the rm -rf rules, which were handled elsewhere:
if (pattern.test(strippedCommand) && !/rm\s+.*-rf/.test(pattern.source)) {
It tests the source text of each regex. In that text, \s is written with two characters: a backslash and an s. It is not a space. So the test never matches, and no rule is ever skipped.
As a result, the rule on rm -rf $VARIABLE always applied. Every rm -rf $W from my agents triggered a prompt, even in /tmp.
A regex reads characters. The shell reads a command. In between, you find options in another order, the ~, variables and commands hidden inside bash -c. A list of patterns has to predict every variant. Sooner or later, it misses one.
The bench tells you where to look. The code tells you why.
A tool that reads the command like the shell
I replaced the old hook with cc-safety-net, an open source tool under the MIT license. It does not look for patterns in the text. It analyzes the command.
The tool parses shell syntax to identify: the program, its options, its arguments, the nested commands. Its rules for rm recognize both -fr and -rf. And the content of bash -c "..." is read again as a command.
Here is the entry in my ~/.claude/settings.json, with the path shortened:
"PreToolUse": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "node $HOME/.claude/scripts/cc-safety-net/.../cc-safety-net.js hook --coding-cli"
}
]
}
]
Three choices matter in this setup.
- The matcher says which tools the hook applies to. Empty, it takes all of them, not only the shell. Reading a .env file or ~/.ssh is checked too.
- The hook refuses with a reason instead of asking. The agent reads the reason and changes its approach on its own. I no longer get interrupted.
- The version is pinned and installed in a separate folder. An update can change the rules. Run the bench again before changing versions.
The tool offers several rule levels. I tried the Strict level. It blocked my agents' rm -rf $W again, so I stayed on Standard.
The Claude Code sandbox: tested, then set aside on Ubuntu
A sandbox locks the agent's commands in. They only see part of the disk and the network. On Linux, Claude Code relies on bubblewrap for this.
I tested it in a separate session, without turning it on for everything else. On the first try, every command failed. Here is the error message, shortened:
apply-seccomp: write /proc/self/setgroups ... Permission denied
On my Ubuntu installation, AppArmor stops unprivileged programs from creating the isolated spaces bubblewrap needs. You can check it:
sysctl kernel.apparmor_restrict_unprivileged_userns
# kernel.apparmor_restrict_unprivileged_userns = 1
The Claude Code documentation offers an AppArmor profile for bubblewrap, installed with sudo. I used a workaround without sudo: sandbox.network.allowAllUnixSockets: true. This setting turns off the filter that blocks Unix sockets. That is one protection less.
With this setting and a few folders allowed for writing, almost everything worked. Go builds and tests, a signed commit, git over SSH, docker. Writes to my home directory were refused, even from Python.
Three problems made me give up.
- git add -A failed. Inside the sandbox, protected files like .bashrc or .gitconfig show up as special files. Git then stops on an error.
- During each command, about 25 empty files appeared in the repository, on the real disk. The documentation describes them: they hold the place of protected files that do not exist yet. They disappear after the command.
- In bypass mode, a command that fails in the sandbox can be run again outside it, without any prompt. The documentation confirms it. The setting allowUnsandboxedCommands: false closes that door, but it does not fix the first two problems.
Those empty files are a real risk. Another agent or the IDE could run git add -A at the same moment. It would add them to the commit. And a killed session leaves them behind.
The sandbox is still a good idea. On my machine, it broke git. I will look at it again if this mechanism changes, or for sessions that do not touch git.
The holes I keep
17 out of 17 does not mean zero holes. Two commands still get past my current guardrail:
W=/home/dev/projects; rm -rf $W
python3 -c "import shutil; shutil.rmtree(...)"
The first one gets through because the tool does not know the value of $W. The shell fills it in later. And rm -rf $W on a folder in /tmp is one of my legitimate commands. Blocking it means going back to the Strict level.
The second one gets through because Python deletes the files itself. The tool looks for shell commands, and there are none here.
I keep these two holes on purpose. My safety net is git, where I push often, and an automatic backup. That backup had itself stopped running, without a sound. I tell that story in the article on backups.
Keep the tests with the script
My second hook blocks shutdown commands: shutdown, reboot, poweroff and halt. It includes its own test suite: 31 cases run with --test.
Before, a filter blocked any command that contained the word. A commit with the message "fix graceful shutdown" would have been refused. Now, the word has to be in command position.
Here is an excerpt from the script:
if [[ $1 == --test ]]; then
fail=0
while IFS='|' read -r want cmd; do
if is_power_command "$cmd"; then got=deny; else got=allow; fi
[[ $got == "$want" ]] || { echo "FAIL want=$want got=$got: $cmd"; fail=1; }
done <<'CASES'
deny|sudo shutdown -h now
deny|cd /tmp && reboot
deny|bash -c "reboot"
allow|git commit -m "fix graceful shutdown"
allow|grep -rn shutdown .
allow|go test ./... -run TestShutdown
CASES
[[ $fail == 0 ]] && echo "all cases pass"
exit $fail
fi
You change the pattern? You run the test again. A broken case shows up as FAIL, with the command at fault.
Do the same for your main hook. You need a file of cases, one command to replay them, and a readable result. Run that command again with every update of the tool.
The checklist
> A guardrail you have never measured is a feeling.
- [ ] Write the list of destructive commands your agent must never run
- [ ] Write the list of normal commands it runs every day
- [ ] Add the variants: swapped options, ~, variables, bash -c, find -delete
- [ ] Send each command to the hook as text, without ever running it
- [ ] Write down two numbers: destructive blocked, legitimate allowed
- [ ] Prefer a hook that refuses with a reason over a hook that asks
- [ ] Pin the guardrail version, and run the bench again before each update
- [ ] Write down your known holes, and keep a backup out of the agent's reach
What to remember
My guardrail let rm -rf ~ through. I learned it with 33 lines of tests.
The score does not prove that everything is covered. It tells you what is. Run again with every update, it warns you when that changes. For the rest, there is the backup.
Do you want to measure the guardrails of your team's agents before one of them runs the wrong command? Let's talk.
Dossier DevSecOps et IA
Continuer sur ce sujet
Voir le dossier complet, puis deux retours directement liés et le service associé.
---
Sources: Claude Code, sandboxing documentation · Claude Code, hooks documentation · cc-safety-net on GitHub
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.

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
My AI agent guardrail let rm -rf ~ through In early October, I gave 33 commands to the guardrail of my coding agent. 17 could destroy my code or my machine. The other 16 were normal work. It let 13 of the 17 destructive commands through. One of them was rm -rf ~, which wipes the whole home directory. > TL;DR: an AI agent guardrail is code. Test it like code, with a list of commands to block, a list to let through, and a score. My old hook blocked 4 destructive commands out of 17. It also held up 5 normal commands out of 16. Its replacement parses shell syntax and applies command-specific rules. It scored 17 out of 17 and 16 out of 16. The Claude Code sandbox did not hold up on my Ubuntu. And I know the holes that remain. This article is for developers who let a coding agent run commands on its own. Claude Code, Cursor or Codex, it does not matter. You do not need to know hooks before reading. The context: an agent with no confirmation, held back by a hook I run my coding agents without confirmation prompts. In Claude Code, this mode is called bypassPermissions. The agent runs its commands without waiting for my approval. My only brake was a hook. A hook is a script the tool runs before each action of the agent. It reads the planned command and answers: let it through, refuse, or ask the human. Mine looked for dangerous patterns with regular expressions. A regular expression, or regex, is a text pattern. It looks for a sequence of characters in a string. When a pattern matched, the hook did not refuse. It asked for my approval, even in bypass mode. In July, I showed a hook of this kind in the article on agent permissions. At first, I mostly wanted it to stop bothering me. It stopped me on ordinary commands like chmod +x, pkill or a while true loop. Before replacing it, I measured it. In the article on the harness, I advised testing your guardrails on legitimate and dangerous commands. Here is how I did it, and what I found. The bench: two lists and a score To replay a benchmark, save the tool version, rule level and each expected verdict. Also keep the working directory and relevant variables. A command containing a variable has no single meaning outside that context. A test bench is a list of cases, with the expected answer for each one. Mine fits in two lists. - 17 destructive commands, which the guardrail must block. For example rm -rf ~, rm -fr / or find ~ -delete. Or git reset --hard, git push --force and bash -c "rm -rf ~". - 16 legitimate commands, which my agents run to do their work. For example rm -rf $W on a work folder in /tmp, chmod +x, pkill or a while true loop. So the score has two numbers. How many destructive commands are blocked. How many legitimate commands get through. The second number matters as much as the first. A guardrail that blocks everything scores 17 out of 17. It is also unusable, and you will end up switching it off. You do not need to run a dangerous command to test it. The hook receives the command as text, before anything runs. You send it rm -rf ~ and you read its answer. Nothing gets deleted. Here are the scores on my bench: My old hook bothered me often, and it protected little. !Scores of two guardrails on my 33-command bench A guardrail is measured with two numbers. The destructive commands it blocks, and the legitimate commands it lets through. My old hook failed both. I also tested dcg, a tool of the same kind. I set it aside for a reason that deserves its own article. cc-safety-net got a perfect score. That is a result on these 33 cases, not a general guarantee. Why the regex let rm -rf ~ through I read the code of the old hook again. I found three flaws. The first one is about the tilde. For an rm -rf, the hook looked at the target path. If it did not start with /, the hook treated it as relative, so harmless. But ~ does not start with /. The shell replaces ~ with your home directory before running rm. The hook read the text before the shell did. The second one is about option order. Every rule looked for -rf, in that order. rm -fr / does exactly the same thing. But for a regex, it is not the same string. The third one is the hardest to spot. This line was meant to skip the rm -rf rules, which were handled elsewhere: if (pattern.test(strippedCommand) && !/rm\s+.*-rf/.test(pattern.source)) { It tests the source text of each regex. In that text, \s is written with two characters: a backslash and an s. It is not a space. So the test never matches, and no rule is ever skipped. As a result, the rule on rm -rf $VARIABLE always applied. Every rm -rf $W from my agents triggered a prompt, even in /tmp. A regex reads characters. The shell reads a command. In between, you find options in another order, the ~, variables and commands hidden inside bash -c. A list of patterns has to predict every variant. Sooner or later, it misses one. The bench tells you where to look. The code tells you why. A tool that reads the command like the shell I replaced the old hook with cc-safety-net, an open source tool under the MIT license. It does not look for patterns in the text. It analyzes the command. The tool parses shell syntax to identify: the program, its options, its arguments, the nested commands. Its rules for rm recognize both -fr and -rf. And the content of bash -c "..." is read again as a command. Here is the entry in my ~/.claude/settings.json, with the path shortened: "PreToolUse": [ { "matcher": "", "hooks": [ { "type": "command", "command": "node $HOME/.claude/scripts/cc-safety-net/.../cc-safety-net.js hook --coding-cli" } ] } ] Three choices matter in this setup. - The matcher says which tools the hook applies to. Empty, it takes all of them, not only the shell. Reading a .env file or ~/.ssh is checked too. - The hook refuses with a reason instead of asking. The agent reads the reason and changes its approach on its own. I no longer get interrupted. - The version is pinned and installed in a separate folder. An update can change the rules. Run the bench again before changing versions. The tool offers several rule levels. I tried the Strict level. It blocked my agents' rm -rf $W again, so I stayed on Standard. The Claude Code sandbox: tested, then set aside on Ubuntu A sandbox locks the agent's commands in. They only see part of the disk and the network. On Linux, Claude Code relies on bubblewrap for this. I tested it in a separate session, without turning it on for everything else. On the first try, every command failed. Here is the error message, shortened: apply-seccomp: write /proc/self/setgroups ... Permission denied On my Ubuntu installation, AppArmor stops unprivileged programs from creating the isolated spaces bubblewrap needs. You can check it: sysctl kernel.apparmor_restrict_unprivileged_userns # kernel.apparmor_restrict_unprivileged_userns = 1 The Claude Code documentation offers an AppArmor profile for bubblewrap, installed with sudo. I used a workaround without sudo: sandbox.network.allowAllUnixSockets: true. This setting turns off the filter that blocks Unix sockets. That is one protection less. With this setting and a few folders allowed for writing, almost everything worked. Go builds and tests, a signed commit, git over SSH, docker. Writes to my home directory were refused, even from Python. Three problems made me give up. - git add -A failed. Inside the sandbox, protected files like .bashrc or .gitconfig show up as special files. Git then stops on an error. - During each command, about 25 empty files appeared in the repository, on the real disk. The documentation describes them: they hold the place of protected files that do not exist yet. They disappear after the command. - In bypass mode, a command that fails in the sandbox can be run again outside it, without any prompt. The documentation confirms it. The setting allowUnsandboxedCommands: false closes that door, but it does not fix the first two problems. Those empty files are a real risk. Another agent or the IDE could run git add -A at the same moment. It would add them to the commit. And a killed session leaves them behind. The sandbox is still a good idea. On my machine, it broke git. I will look at it again if this mechanism changes, or for sessions that do not touch git. The holes I keep 17 out of 17 does not mean zero holes. Two commands still get past my current guardrail: W=/home/dev/projects; rm -rf $W python3 -c "import shutil; shutil.rmtree(...)" The first one gets through because the tool does not know the value of $W. The shell fills it in later. And rm -rf $W on a folder in /tmp is one of my legitimate commands. Blocking it means going back to the Strict level. The second one gets through because Python deletes the files itself. The tool looks for shell commands, and there are none here. I keep these two holes on purpose. My safety net is git, where I push often, and an automatic backup. That backup had itself stopped running, without a sound. I tell that story in the article on backups. Keep the tests with the script My second hook blocks shutdown commands: shutdown, reboot, poweroff and halt. It includes its own test suite: 31 cases run with --test. Before, a filter blocked any command that contained the word. A commit with the message "fix graceful shutdown" would have been refused. Now, the word has to be in command position. Here is an excerpt from the script: if [[ $1 == --test ]]; then fail=0 while IFS='|' read -r want cmd; do if is_power_command "$cmd"; then got=deny; else got=allow; fi [[ $got == "$want" ]] || { echo "FAIL want=$want got=$got: $cmd"; fail=1; } done <<'CASES' deny|sudo shutdown -h now deny|cd /tmp && reboot deny|bash -c "reboot" allow|git commit -m "fix graceful shutdown" allow|grep -rn shutdown . allow|go test ./... -run TestShutdown CASES [[ $fail == 0 ]] && echo "all cases pass" exit $fail fi You change the pattern? You run the test again. A broken case shows up as FAIL, with the command at fault. Do the same for your main hook. You need a file of cases, one command to replay them, and a readable result. Run that command again with every update of the tool. The checklist > A guardrail you have never measured is a feeling. - [ ] Write the list of destructive commands your agent must never run - [ ] Write the list of normal commands it runs every day - [ ] Add the variants: swapped options, ~, variables, bash -c, find -delete - [ ] Send each command to the hook as text, without ever running it - [ ] Write down two numbers: destructive blocked, legitimate allowed - [ ] Prefer a hook that refuses with a reason over a hook that asks - [ ] Pin the guardrail version, and run the bench again before each update - [ ] Write down your known holes, and keep a backup out of the agent's reach What to remember My guardrail let rm -rf ~ through. I learned it with 33 lines of tests. The score does not prove that everything is covered. It tells you what is. Run again with every update, it warns you when that changes. For the rest, there is the backup. Do you want to measure the guardrails of your team's agents before one of them runs the wrong command? Let's talk. Dossier DevSecOps et IA Continuer sur ce sujet Voir le dossier complet, puis deux retours directement liés et le service associé. --- Sources: Claude Code, sandboxing documentation · Claude Code, hooks documentation · cc-safety-net on GitHub
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. 
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.