Rootless sandboxing for AI CLI agents (Claude, Codex, Gemini) in corporate environments where you don't have root on the host.
| Tool | What it does | Root? | Deps |
|---|---|---|---|
| compartment-user | Landlock + seccomp + env sanitize + audit | no | none |
| compartment-root | Full namespace container + seccomp + audit | yes | none |
Both tools share the same profile file format and header (compartment.h).
compartment-user is the primary tool for rootless environments.
# Build (zero dependencies)
make
# Run an AI agent in a sandbox
./compartment-user -- claude --model claude-opus-4-6
# See what would be applied without running
./compartment-user --dry-run -- claude
# Verify kernel support
./compartment-user --verify# AI agent (default): system paths read-only, HOME+/tmp writable,
# dangerous syscalls blocked, env sanitized
./compartment-user -- claude
# Strict: ai-agent + extra syscall blocks
./compartment-user --profile strict -- claude
# Custom: explicit paths and blocks
./compartment-user --ro /usr --rw /workspace --block ptrace -- ./my-agent
# Load profile from file (by name — searches config dirs)
./compartment-user --profile my-custom -- claude
# Load profile from explicit path
./compartment-user --profile /path/to/profile.conf -- claudeInstead of CLI flags, profiles can be defined in .conf files.
compartment-user searches for them in order:
- Explicit path —
--profile /path/to/file.conf - User config —
~/.config/compartment/<name>.conf - System config —
/etc/compartment/<name>.conf - Built-in —
ai-agentandstrict(compiled in)
If a file is found, it is loaded. Otherwise the built-in profile is used
(if one exists with that name). This means you can override the built-in
ai-agent profile by placing a file at ~/.config/compartment/ai-agent.conf.
One directive per line. Blank lines and # comments are ignored.
$HOME and $USER are expanded in values.
# ~/.config/compartment/ai-agent.conf
# Inherit another profile (loads it first, then applies these rules on top)
# inherit ai-agent
# Filesystem (Landlock)
ro /usr
ro /lib
ro /lib64
ro /lib32
ro /etc
ro /bin
ro /sbin
ro /proc
ro /dev
ro /sys
ro /run
ro /var/lib
rw /tmp
rw $HOME
# Syscall blocklist (seccomp)
block ptrace
block mount
block umount2
block reboot
block kexec_load
block kexec_file_load
block init_module
block finit_module
block delete_module
block pivot_root
block chroot
block unshare
block setns
block keyctl
block add_key
block request_key
block bpf
block userfaultfd
block perf_event_open
block process_vm_readv
block process_vm_writev
block acct
block swapon
block swapoff
block settimeofday
block clock_settime
block clock_adjtime
block adjtimex
# Environment deny list
env-deny LD_PRELOAD
env-deny LD_LIBRARY_PATH
env-deny LD_AUDIT
env-deny DYLD_INSERT_LIBRARIES
env-deny DYLD_LIBRARY_PATH
env-deny _JAVA_OPTIONS
env-deny JAVA_TOOL_OPTIONS
# Feature toggles (on/off)
landlock on
seccomp on
no-new-privs on
env-sanitize on
# Audit logging
audit on
audit-log /var/tmp/compartment-audit-$USER
# Working directory
# workdir $HOME/projects| Directive | Value | Example |
|---|---|---|
ro |
path | ro /usr |
rw |
path | rw $HOME |
exec |
path | exec /opt/bin |
block |
syscall name | block ptrace |
env-deny |
variable name | env-deny LD_PRELOAD |
landlock |
on/off | landlock on |
seccomp |
on/off | seccomp on |
no-new-privs |
on/off | no-new-privs on |
env-sanitize |
on/off | env-sanitize on |
audit |
on/off | audit on |
audit-log |
directory path | audit-log /var/tmp/my-audit |
workdir |
path | workdir $HOME/projects |
inherit |
profile name | inherit ai-agent |
Profiles can inherit from other profiles with the inherit directive.
The inherited profile is loaded first, then the current file's directives
are applied on top (additive — paths and blocks accumulate).
# ~/.config/compartment/strict.conf
inherit ai-agent
# Add extra syscall blocks on top of ai-agent defaults
block personality
block lookup_dcookie
block vhangup
block quotactl
block mbind
block move_pagesInheritance depth is limited to 2 levels to prevent loops.
compartment-user logs events to stderr and to daily log files.
# Stderr + default log dir (/var/tmp/compartment-audit-$UID/)
./compartment-user --audit -- claude
# Stderr + custom log dir
./compartment-user --audit-log /path/to/logs -- claude
# Via profile file
audit on
audit-log /var/tmp/my-audit-dirDefault: /var/tmp/compartment-audit-<UID>/YYYY-MM-DD.log
Why /var/tmp?
- World-writable with sticky bit — any user can create dirs, no root needed
- Survives reboot — unlike
/tmpon tmpfs distros - Not in the Landlock allowed set — the sandboxed child process cannot see, read, or tamper with audit logs
The log file is opened before Landlock is applied. The file descriptor
has O_CLOEXEC, so it does not leak to the exec'd child. This gives us:
1. audit_log_open() ← opens fd (no restrictions yet)
2. audit_log() ← writes COMPARTMENT_START event
3. apply_landlock() ← from here, /var/tmp is inaccessible
4. apply_seccomp()
5. execv(child) ← child inherits restrictions, fd is closed
The child literally cannot open(), stat(), or ls the audit directory.
- Directory:
0700(only your user) - Files:
0600(only your user) - Writes are
O_APPEND(atomic for lines under 4096 bytes)
One line per event, structured for grep:
[2026-03-31 01:27:10] user=claude uid=1000 event=COMPARTMENT_START ppid_chain=1234->5678->1 cwd=/home/claude/project tty=/dev/pts/0 command=/bin/echo profile=ai-agent landlock=1 seccomp=1 paths=14 blocked=30
Fields: timestamp, user, uid, event type, PPID chain (who launched us), working directory, TTY, and event-specific detail.
No rotation logic needed — the date is the rotation. One file per day. Clean up old logs with cron:
find /var/tmp/compartment-audit-$(id -u) -name '*.log' -mtime +30 -deletecompartment-user can transparently intercept /bin/bash (or any shell)
so that every subprocess spawned by an AI agent gets sandboxed — even
when the agent hardcodes /bin/bash -c "...".
When invoked via a symlink with a name other than compartment-user,
it detects argv[0], applies the ai-agent sandbox profile, and execs
the real shell from a configurable directory.
/bin/bash (symlink) → compartment-user
→ applies Landlock + seccomp + env sanitize
→ execs /bin/shells/bash (the real shell)
The real shell directory defaults to /bin/shells and can be overridden:
- At compile time:
cc -DREAL_SHELL_DIR='"/opt/shells"' ... - At runtime:
export COMPARTMENT_SHELL_DIR=/path/to/real/shells - Via
make hardened: generates a random 12-char suffix (e.g./bin/.shells_a7f3b2c1e4cc) so the path isn't guessable.sandbox.shalso randomizes the path per invocation.
In a corporate environment you can't modify /bin/bash on the host.
Two options for intercepting shell calls inside a sandbox namespace.
Intercepts all shell calls, including hardcoded absolute paths like
/bin/bash. Requires a mount namespace (provided by sandbox.sh
or unshare --mount).
# 1. Enter a user+mount namespace (no root needed on host)
unshare --user --mount --net bash
# 2. Save the real shells
mkdir -p /bin/shells
mount --bind /bin/bash /bin/shells/bash
mount --bind /bin/sh /bin/shells/sh
# repeat for any other shells you want to intercept
# 3. Replace with compartment-user
mount --bind /path/to/compartment-user /bin/bash
mount --bind /path/to/compartment-user /bin/sh
# 4. Now every /bin/bash call hits compartment-user
# The agent doesn't know — it gets a bash with Landlock+seccomp applied
claude --model claude-opus-4-6With sandbox.sh, steps 1-3 happen automatically when
compartment-user is built and found on the system:
# sandbox.sh sets up the namespace and bind mounts
./sandbox.sh claude --model claude-opus-4-6How it works:
sandbox.sh
└── unshare --user --mount --net
├── mount --bind /bin/bash → /bin/shells/bash (save real)
├── mount --bind compartment-user → /bin/bash (replace)
└── exec compartment-user -- claude ...
└── claude spawns: /bin/bash -c "git diff"
→ compartment-user (via bind mount)
→ Landlock + seccomp applied
→ exec /bin/shells/bash -c "git diff"
Pros: Catches everything — absolute paths, shebang lines, system(). Cons: Needs mount namespace. Slightly more setup.
Intercepts PATH-resolved shell calls only. No mount namespace needed.
Simpler but does not catch hardcoded /bin/bash.
# 1. Create a directory for the interceptor symlinks
mkdir -p ~/.local/bin/sandboxed
# 2. Symlink compartment-user as each shell name
ln -s /path/to/compartment-user ~/.local/bin/sandboxed/bash
ln -s /path/to/compartment-user ~/.local/bin/sandboxed/sh
ln -s /path/to/compartment-user ~/.local/bin/sandboxed/zsh
# 3. Tell compartment-user where the real shells are
export COMPARTMENT_SHELL_DIR=/bin
# 4. Prepend to PATH
export PATH=~/.local/bin/sandboxed:$PATH
# 5. Now "bash" resolves to compartment-user, which execs /bin/bash
# after applying the sandbox
claude --model claude-opus-4-6How it works:
claude spawns: bash -c "git diff"
→ PATH lookup finds ~/.local/bin/sandboxed/bash
→ compartment-user (argv[0]="bash")
→ reads COMPARTMENT_SHELL_DIR=/bin
→ Landlock + seccomp applied
→ exec /bin/bash -c "git diff"
Pros: No namespace needed. Works anywhere. Easy to set up.
Cons: Does not catch /bin/bash (absolute path). Scripts with
#!/bin/bash shebang bypass it.
| Option A (bind mount) | Option B (PATH) | |
|---|---|---|
Catches /bin/bash |
yes | no |
Catches bash |
yes | yes |
Catches #!/bin/bash |
yes | no |
| Needs namespace | yes | no |
| Setup complexity | medium | low |
| Use when | sandbox.sh or unshare --mount available |
quick local sandboxing |
Recommendation: Use Option A inside sandbox.sh for production
AI agent containment. Use Option B for quick local experiments.
compartment-user (Landlock + seccomp) and bubblewrap (namespaces) are complementary, not alternatives:
# bubblewrap provides: mount isolation, PID namespace, net namespace
# compartment-user provides: Landlock path rules, seccomp syscall filter
bwrap \
--ro-bind / / \
--dev /dev \
--tmpfs /tmp \
--bind $HOME $HOME \
--unshare-net \
--unshare-pid \
-- compartment-user -- claude --model claude-opus-4-6Or use compartment-user in shell-replacement mode inside bwrap:
bwrap \
--ro-bind / / \
--dev /dev \
--tmpfs /tmp \
--bind $HOME $HOME \
--ro-bind /path/to/compartment-user /bin/bash \
--ro-bind /bin/bash /bin/shells/bash \
--unshare-net \
-- claude --model claude-opus-4-6compartment-root provides full namespace isolation. It uses the same profile file format and shared header as compartment-user.
# Build (zero dependencies)
make compartment-root
# Full container from profile
./compartment-root --profile examples/container.conf -- /bin/sh
# CLI flags (override profile)
./compartment-root --rootdir /srv/jail -u 1000 -g 1000 -U svc -l -- /bin/bash
# Dry run
./compartment-root --profile examples/container.conf --dry-run -- /bin/sh
# With audit logging
./compartment-root --profile examples/container.conf --audit -- /bin/sh- Load profile file (if
--profile) - Resolve username to UID/GID (host
/etc/passwd) - Open audit log (host filesystem, before namespace setup)
clone()with new namespaces (UTS, mount, PID, IPC, net, user)- Parent: UID/GID range mapping (0-65535), cgroup assignment
- Child:
pivot_root(old root fully unmounted) - Child: Minimal
/dev, masked/proc - Child: Hostname isolation, optional loopback, resource limits
- Child: Capability bounding-set drop (raw
prctl— while still root) - Child:
PR_SET_KEEPCAPS+ privilege drop (setgid/setuid) 10b. Child:capset()— restore effective+permitted caps for service user - Child: Environment sanitize
- Child: seccomp BPF (raw, fatal on failure)
- Child: Close inherited FDs,
execthe command
| Directive | Example | Description |
|---|---|---|
rootdir |
rootdir /srv/jail |
pivot_root target (required) |
uid |
uid 1000 |
Override UID for privilege drop |
gid |
gid 1000 |
Override GID for privilege drop |
username |
username svc |
Service user for privilege drop (required) |
netns |
netns my-ns |
Join existing network namespace |
loopback |
loopback on |
Bring up lo in new netns |
cgroup |
cgroup /sys/fs/cgroup/cpu/sandbox |
Cgroup assignment (repeatable) |
cap-allow |
cap-allow CAP_NET_BIND_SERVICE |
Preserve capability for service user (repeatable) |
mount-mask |
mount-mask /proc/timer_list |
Extra path to mask (repeatable) |
compartment.h — shared code: profiles, audit, seccomp BPF, env sanitize
compartment-user.c — Landlock + seccomp (zero deps, rootless)
compartment-root.c — Full namespace container (zero deps, requires root)
sandbox.sh — Network namespace + proxy bridge
Makefile — Build targets
HOWTO.md — This file
DESIGN.md — Architecture, security review, lineage from shell-guard
examples/
ai-agent.conf — Profile for Claude/Codex/Gemini
strict.conf — Locked-down profile (inherits ai-agent)
container.conf — Full namespace isolation profile
dev.conf — Relaxed profile for development
archive/
shell-guard/ — Archived shell-replacement tool (~2003, self-contained)