Sandboxing AI Coding Agents with FreeBSD Jails
A two-machine architecture for pragmatic agent isolation
Table of Contents
- 1. Abstract
- 2. The Problem
- 3. Architecture
- 4. Isolation Properties
- 5. Deployment
- 6. Test Protocol
- 7. Cost Comparison
- 8. Emacs Development Workflow with TRAMP
- 9. Conclusion
- 10. References
1. Abstract
AI coding agents need broad system access to be useful – filesystem, network, git, build tools – but granting that access on a shared machine creates security risks ranging from credential exposure to lateral movement. This paper presents a practical, implemented architecture using two commodity FreeBSD machines and three layers of isolation: a restricted host for operator sessions, a sandbox host with FreeBSD thin jails per agent identity, and a one-way SSH bulkhead between them. Total hardware cost is under $400. We describe the trust model, demonstrate verified isolation properties, and provide a step-by-step deployment guide.
2. The Problem
Modern AI coding agents (Claude Code, Copilot Workspace, Cursor, Aider, etc.) require:
- Read/write access to source trees
- Build and test execution (compilers, interpreters, test runners)
- Git operations including push to remote forges
- API access to forge APIs, LLM APIs, and package registries
- Sometimes: package installation, service management, system configuration
Running these agents on a developer's primary machine means an agent error, hallucination, or adversarial prompt injection could read SSH keys, API tokens, browser sessions, or credentials for unrelated services. On a shared machine, one agent session can observe or interfere with another.
Existing container solutions (Docker, Podman) provide process isolation but typically run on the developer's primary machine, leaving the fundamental trust boundary problem unsolved. Cloud VMs add latency and recurring cost.
3. Architecture
3.1. Two Machines, One Direction
graph TD dev["Operator Laptop"] subgraph LAN["Local Network"] restricted["Restricted Host<br/>(no root, scoped creds)<br/>Operator sessions"] sandbox["Sandbox Host<br/>(root, jails)<br/>Agent workloads"] end subgraph Forges["External Forges"] forge1["Forge A"] forge2["Forge B"] end dev -->|"SSH / TRAMP"| restricted restricted -->|"SSH (one-way)"| sandbox sandbox -.-x|"BLOCKED"| restricted restricted --> forge1 sandbox --> forge2 style restricted fill:#2d5016,color:#fff style sandbox fill:#8b1a1a,color:#fff
- Restricted host: Where the operator works. Runs editor, git, agent CLI. Has forge credentials but no root access. Cannot install packages or modify system files.
- Sandbox host: Where agent workloads execute. Has root (for package installation, jail management). Runs one thin jail per agent identity, each with scoped credentials and resource limits.
- Bulkhead: The sandbox host cannot SSH back to the restricted host. Enforced by firewall rules and absence of authorized keys. If the sandbox is compromised, the restricted host (where real credentials live) is unreachable.
3.2. Identity Layering
Each agent identity is isolated at multiple levels:
| Level | Mechanism | What It Provides |
|---|---|---|
| 1. Unix user | adduser agent-N |
File permission boundaries, own home dir |
| 2. Credentials | Scoped tokens per user | Agent can only access its own forge org |
| 3. Thin jail | Bastille/jail(8) | Kernel-enforced filesystem, process, network isolation |
| 4. ZFS quota | zfs set quota=5G |
Disk usage limits per jail |
| 5. rctl | rctl -a jail:...:memoryuse:deny=2G |
CPU, memory, process count limits (requires reboot) |
| 6. pf firewall | Egress allowlist | Jails can only reach forge hosts and pkg mirrors |
3.2.1. What Are Thin Jails?
Thin jails share the base FreeBSD system read-only via NullFS mounts. Each jail only stores its delta – packages, configuration, and user data.
| Component | Storage |
|---|---|
| Base system (shared) | ~459MB (mounted read-only) |
| Per-jail delta | ~380MB (packages + config) |
| Total for 5 jails + base | ~2.8GB |
This is why jail creation takes ~3 seconds instead of minutes – there's no full OS copy, just a ZFS dataset and NullFS mount. On a host with 379GB free, you can run dozens of agents without storage concerns.
3.2.2. Credential Scoping
Each identity has its own:
~agent-N/ ├── .config/gh/hosts.yml # Forge CLI auth (scoped token) ├── .gitconfig # Identity (name, email) ├── .ssh/id_ed25519 # SSH key (registered on specific forge org) ├── .claude/ # AI agent config and auth │ └── .credentials.json └── ~/ghq/ # Repository tree
The operator's credentials are never copied into agent jails. Each agent gets the minimum credentials needed for its forge org.
3.3. The --dangerously-skip-permissions Trust Model
Claude Code's --dangerously-skip-permissions flag removes the
interactive confirmation prompts that normally gate file writes, command
execution, and network requests. On an unprotected machine, this flag is
genuinely dangerous – an agent error could rm -rf your home directory,
exfiltrate credentials, or install malware.
Inside a Bastille jail, the calculus changes:
| Risk | Without Jail | With Jail |
|---|---|---|
| Delete files | Entire home dir | Only jail's ZFS dataset (5G quota) |
| Read credentials | All ~/.ssh, ~/.config |
Only scoped jail credentials |
| Install packages | System-wide | Jail-local only |
| Network exfiltration | Unrestricted | pf egress rules (ports 22/80/443 only) |
| Lateral movement | SSH to other machines | Cannot reach restricted host |
| Persistence | Survives reboot | zfs rollback reverts everything |
The flag goes from "dangerous" to "appropriate" once the jail provides the containment that the flag removes. The jail is the permission boundary; the flag just removes the ceremony.
Inside jails, we alias:
alias claude='claude --dangerously-skip-permissions'
This is arguably the paper's most practical insight: the same flag that's reckless on a developer laptop becomes reasonable when the jail constrains its blast radius to a ZFS dataset with a 5G quota and pf egress rules.
3.4. Command Hierarchy
Three levels of access, each with different capabilities:
3.4.1. Level 0: Operator (restricted host)
# Direct access to all forge credentials ssh -T git@forge-a.com # Authenticated gh api user # Full token scope # Can shell into sandbox host ssh sandbox-host # Cannot install packages or modify system sudo pkg install foo # DENIED (no sudo)
3.4.2. Level 1: Operator on Sandbox (host user, sudo)
# Manage jails sudo bastille list # See all jails sudo bastille console agent-N # Enter a jail sudo bastille cmd agent-N CMD # Run command in jail # Switch to agent Unix user (non-jailed, for testing) sudo su - agent-N # Full host access as that user # Install packages, manage services sudo pkg install foo # Works (has sudo)
3.4.3. Level 2: Agent (inside jail)
# Scoped forge access gh api user # Only sees agent's forge org git clone https://... # Works (HTTPS allowed by pf) # AI agent sessions claude # Runs with jailed credentials # Cannot escape ps aux # Only sees jail's own processes (~5) ls /home # Empty (host /home not mounted) ping anything # DENIED (raw sockets disabled) mount anything # DENIED (jail restriction)
4. Isolation Properties
Verified on a running implementation (FreeBSD 14.3, Bastille 1.3.2):
4.1. What Jails Prevent
| Attack Vector | Blocked? | Mechanism |
|---|---|---|
| Read host /home | Yes | Jail root is isolated filesystem |
| See host processes | Yes | Kernel process namespace |
| Read other jail credentials | Yes | Other jail paths don't exist |
| Raw sockets (network scan) | Yes | Kernel blocks raw sockets in jails |
| Mount filesystems | Yes | Kernel blocks mount in jails |
| Access non-allowed ports | Yes | pf egress rules |
| Consume unlimited disk | Yes | ZFS quotas (5G default) |
| Consume unlimited CPU/RAM | Yes | rctl limits (after RACCT enable) |
4.2. What Jails Allow (by design)
| Capability | How |
|---|---|
| Git clone/push (HTTPS) | pf allows port 443 outbound |
| Forge API calls | pf allows port 443 outbound |
| SSH to forges | pf allows port 22 outbound |
| DNS resolution | pf allows port 53 outbound |
| Package installation | bastille pkg from host |
| AI agent sessions | Claude Code runs inside jail |
4.3. Known Limitations
- A 2025 security audit found ~50 kernel vulnerabilities enabling jail escapes. Jails defend against accidents and casual attacks, not sophisticated adversaries targeting the jail boundary.
- The network bulkhead (sandbox cannot reach restricted host) is the primary security boundary, not the jail itself.
- Credential copying is manual. No automated sync or rotation.
4.4. FreeBSD Jail Gotchas for AI Agents
Running AI coding agents (Claude Code, etc.) inside FreeBSD jails requires specific configuration that isn't obvious from standard jail documentation:
4.4.1. bash is Required
FreeBSD jails default to /bin/sh as root shell. Claude Code requires
bash and checks the SHELL environment variable. Without it:
Error: No suitable shell found. Claude CLI requires a Posix shell environment. Please ensure you have a valid shell installed and the SHELL environment variable set.
Fix:
sudo bastille pkg <jail> install -y bash sudo bastille cmd <jail> chsh -s /usr/local/bin/bash root echo 'export SHELL=/usr/local/bin/bash' >> /root/.profile echo 'export SHELL=/usr/local/bin/bash' >> /root/.bashrc
4.4.2. gmake vs make
FreeBSD's system make is BSD make, not GNU make. Most project
Makefiles (especially those from GitHub) require GNU make. Install
gmake and use it everywhere, or alias it:
sudo bastille pkg <jail> install -y gmake # In jail: gmake check, gmake build, etc. # Or: echo 'alias make=gmake' >> /root/.bashrc
4.4.3. npm Global Installs
npm install -g works inside jails but the global prefix must be
writable. Since jail root is actual root (scoped to the jail), this
works by default. If running as a non-root jail user, set:
mkdir -p ~/.npm-global npm config set prefix '~/.npm-global' echo 'export PATH=~/.npm-global/bin:$PATH' >> ~/.bashrc
4.4.4. ghq Clone Sync
Jails start with empty home directories. Use a sync script to clone repos from the agent's forge account:
#!/bin/sh # ghq-sync.sh - run inside jail repos=$(gh repo list <org> --json name --jq '.[].name') for repo in $repos; do ghq get https://github.com/<org>/$repo 2>/dev/null || true done
4.4.5. DNS Resolution
Jails inherit /etc/resolv.conf from the host at creation time. If
the host uses Tailscale DNS (100.100.100.100), the jail will too. If
DNS stops working after jail creation, regenerate:
sudo bastille cmd <jail> sh -c 'echo "nameserver 8.8.8.8" > /etc/resolv.conf'
4.4.6. Concurrent Agent Sessions
Two agent sessions using the same forge identity (same GitHub token) from different jails will work – forge APIs are stateless. However, if both push to the same repo branch simultaneously, you'll get conflicts. Scope each agent to different repos or branches.
5. Deployment
5.1. Hardware
Any two x86-64 machines with 16GB+ RAM and NVMe storage. Mini PCs work well. Total cost ~$300-400.
| Role | Example Hardware | Cost |
|---|---|---|
| Restricted host | AZW/Beelink mini PC | ~$170 |
| Sandbox host | AZW/Beelink mini PC | ~$170 |
5.2. Software Stack
| Component | Purpose |
|---|---|
| FreeBSD 14.3+ | Operating system (both hosts) |
| Bastille | Jail management |
| ZFS | Filesystem with quotas and snapshots |
| pf | Firewall with NAT and egress filtering |
| rctl/RACCT | Resource limits per jail |
| gh, git, ghq | Forge CLI tools |
| bd (beads) | Git-backed issue tracker for agent task coordination |
| node, npm | For Claude Code |
| go | Build bd and other Go tools (host only) |
| guile, emacs, python | Agent toolchain (varies by identity) |
5.3. Step-by-Step
5.3.1. 1. Sandbox Host Setup
# Install Bastille sudo pkg install -y bastille # Configure ZFS-backed jails sudo sysrc bastille_enable=YES sudo sysrc -f /usr/local/etc/bastille/bastille.conf \ bastille_zfs_enable=YES bastille_zfs_zpool=zroot # Create loopback interface for jails sudo sysrc cloned_interfaces='lo1' ifconfig_lo1_name='bastille0' sudo service netif cloneup # Enable pf and IP forwarding sudo sysrc pf_enable=YES gateway_enable=YES sudo kldload pf # Bootstrap base system (~200MB, ~23 seconds) sudo bastille bootstrap 14.3-RELEASE
5.3.2. 2. Create pf Rules
# /etc/pf.conf ext_if="re0" # Adjust to your interface jail_net="10.0.0.0/24" restricted_host="192.168.x.x" # Restricted host IP set skip on lo0 nat on $ext_if from $jail_net to any -> ($ext_if) block in all block out all pass in on $ext_if proto tcp from $restricted_host to any port 22 pass out on $ext_if proto { tcp udp } from ($ext_if) to any pass out on $ext_if proto udp from $jail_net to any port 53 pass out on $ext_if proto tcp from $jail_net to any port { 22 80 443 } pass on bastille0 all
sudo pfctl -e -f /etc/pf.conf sudo sysctl net.inet.ip.forwarding=1
5.3.3. 3. Create an Agent
# Create Unix user sudo pw useradd agent-alpha -m -s /bin/sh -c 'Agent Alpha' # Generate SSH key sudo -u agent-alpha ssh-keygen -t ed25519 \ -f /home/agent-alpha/.ssh/id_ed25519 -N '' -C 'agent-alpha@sandbox' # Create gitconfig cat <<'EOF' | sudo tee /home/agent-alpha/.gitconfig [user] name = Agent Alpha email = agent-alpha@example.com [init] defaultBranch = main EOF # Create thin jail (~3 seconds) sudo bastille create agent-alpha 14.3-RELEASE 10.0.0.20 # Install toolchain -- bash and gmake are REQUIRED sudo bastille pkg agent-alpha install -y git gh ghq node npm-node24 \ bash gmake guile3 emacs-nox python311 curl # Install Go on the sandbox HOST (not inside jails) sudo pkg install -y go # Build bd (beads issue tracker) on the host, copy static binary into jails # Go builds a single static binary -- no runtime deps needed inside the jail go install github.com/steveyegge/beads/cmd/bd@latest sudo cp ~/go/bin/bd /usr/local/bastille/jails/agent-alpha/root/usr/local/bin/bd # CRITICAL: Set bash as root shell and export SHELL # Claude Code requires bash -- FreeBSD jails default to /bin/sh # which causes: "No suitable shell found. Claude CLI requires a # Posix shell environment." sudo bastille cmd agent-alpha chsh -s /usr/local/bin/bash root echo 'export SHELL=/usr/local/bin/bash' | \ sudo tee -a /usr/local/bastille/jails/agent-alpha/root/root/.profile echo 'export SHELL=/usr/local/bin/bash' | \ sudo tee -a /usr/local/bastille/jails/agent-alpha/root/root/.bashrc # Install AI agent CLI sudo bastille cmd agent-alpha npm install -g @anthropic-ai/claude-code # Copy credentials into jail sudo mkdir -p /usr/local/bastille/jails/agent-alpha/root/root/.config/gh sudo cp /home/agent-alpha/.config/gh/* \ /usr/local/bastille/jails/agent-alpha/root/root/.config/gh/ sudo cp /home/agent-alpha/.gitconfig \ /usr/local/bastille/jails/agent-alpha/root/root/.gitconfig # Copy Claude Code credentials (if agent uses Claude) sudo mkdir -p /usr/local/bastille/jails/agent-alpha/root/root/.claude sudo cp /home/agent-alpha/.claude/.credentials.json \ /usr/local/bastille/jails/agent-alpha/root/root/.claude/ sudo cp /home/agent-alpha/.claude.json \ /usr/local/bastille/jails/agent-alpha/root/root/.claude.json # Set ZFS quota sudo zfs set quota=5G zroot/bastille/jails/agent-alpha/root
5.3.4. 4. Run Agent in Jail
# Interactive shell sudo bastille console agent-alpha # Or run a single command sudo bastille cmd agent-alpha claude --print "explain this codebase" # Or clone and work sudo bastille cmd agent-alpha git clone https://forge.example/org/repo /tmp/work sudo bastille cmd agent-alpha sh -c 'cd /tmp/work && claude'
5.3.5. 5. Verify Isolation
# From host: agent can't see host processes sudo bastille cmd agent-alpha ps aux | wc -l # Should be ~5 # From host: agent can't see host filesystem sudo bastille cmd agent-alpha ls /home # Should be empty # From host: agent can't see other jails sudo bastille cmd agent-alpha ls /usr/local/bastille # No such file # From host: agent identity is correct sudo bastille cmd agent-alpha gh api user --jq '.login'
5.3.6. 6. ZFS Snapshots for Rollback
Before risky agent operations, snapshot the jail:
# Pre-task snapshot sudo zfs snapshot zroot/bastille/jails/agent-alpha/root@pre-task-42 # ... agent does work ... # Review what changed sudo zfs diff zroot/bastille/jails/agent-alpha/root@pre-task-42 # If bad, rollback (destroys all changes since snapshot) sudo bastille stop agent-alpha sudo zfs rollback zroot/bastille/jails/agent-alpha/root@pre-task-42 sudo bastille start agent-alpha # If good, destroy snapshot sudo zfs destroy zroot/bastille/jails/agent-alpha/root@pre-task-42
For production use, automate daily snapshots with retention:
# Cron: 0 3 * * * /usr/local/bin/jail-snap-daily.sh for jail in $(bastille list | tail -n +2 | awk '{print $2}'); do zfs snapshot "zroot/bastille/jails/${jail}/root@daily-$(date +%Y-%m-%d)" done
6. Test Protocol
A formal verification checklist for new jail deployments:
#!/bin/sh # jail-isolation-test.sh <jail-name> JAIL=$1 PASS=0 FAIL=0 test_blocked() { desc="$1"; expected="$2"; shift 2 result=$(sudo bastille cmd "$JAIL" "$@" 2>&1) if echo "$result" | grep -qi "$expected"; then echo "PASS: $desc" PASS=$((PASS + 1)) else echo "FAIL: $desc (expected '$expected', got: $result)" FAIL=$((FAIL + 1)) fi } test_works() { desc="$1"; shift if sudo bastille cmd "$JAIL" "$@" >/dev/null 2>&1; then echo "PASS: $desc" PASS=$((PASS + 1)) else echo "FAIL: $desc (exit code $?)" FAIL=$((FAIL + 1)) fi } echo "=== Isolation Tests for jail: $JAIL ===" echo "" echo "--- Should be BLOCKED ---" count=$(sudo bastille cmd "$JAIL" ps aux 2>&1 | wc -l) if [ "$count" -lt 10 ]; then echo "PASS: Host processes hidden ($count lines, jail only)" PASS=$((PASS + 1)) else echo "FAIL: Host processes visible ($count lines)" FAIL=$((FAIL + 1)) fi test_blocked "Host /home inaccessible" "No such file" ls /home/ test_blocked "Bastille dir hidden" "No such file" ls /usr/local/bastille test_blocked "Raw sockets blocked" "not permitted\|denied" ping -c1 127.0.0.1 echo "" echo "--- Should WORK ---" test_works "Git available" git --version test_works "Forge CLI available" gh --version test_works "Beads tracker available" bd --version test_works "Identity correct" gh api user --jq '.login' test_works "DNS works" host github.com test_works "HTTPS works" fetch -qo /dev/null https://github.com echo "" echo "Results: $PASS passed, $FAIL failed"
7. Cost Comparison
| Approach | Hardware | Monthly | Isolation | Latency |
|---|---|---|---|---|
| Agent on laptop | $0 | $0 | None | Lowest |
| Cloud VM (e2-medium) | $0 | ~$25 | Per-VM | High |
| Two mini PCs + jails | ~$340 | $0 | Kernel-level | LAN |
| Rack server + bhyve | $800+ | $0 | Hypervisor | LAN |
The two-box approach breaks even vs cloud VMs in ~14 months, provides stronger isolation (jails > containers), lower latency (LAN), and complete control over the environment.
8. Emacs Development Workflow with TRAMP
TRAMP (Transparent Remote Access, Multiple Protocols) supports multi-hop connections and custom methods, making it well-suited for editing files inside FreeBSD jails from a remote Emacs session.
8.1. TRAMP Configuration: Custom Bastille Method
TRAMP does not natively support FreeBSD jails. Define a custom
bastille TRAMP method that calls sudo bastille console <jail>:
(with-eval-after-load 'tramp ;; FreeBSD TRAMP performance fix (known process-send-string bug) (setq tramp-chunksize 500) ;; Define bastille method (add-to-list 'tramp-methods '("bastille" (tramp-login-program "sudo") (tramp-login-args (("bastille") ("console") ("%h"))) (tramp-remote-shell "/bin/sh") (tramp-remote-shell-args ("-i" "-c")) (tramp-completion-use-cache nil))) ;; Auto-proxy: jail names matching "agent-.*" route through sandbox ;; This lets you type /bastille:agent-N:/path instead of full multi-hop (add-to-list 'tramp-default-proxies-alist '("agent-.*" nil "/ssh:operator@sandbox:")))
Passwordless sudo is required on the sandbox host. Add to
/usr/local/etc/sudoers.d/bastille:
operator ALL=(root) NOPASSWD: /usr/local/bin/bastille console * operator ALL=(root) NOPASSWD: /usr/local/bin/bastille cmd * operator ALL=(root) NOPASSWD: /usr/local/bin/bastille list operator ALL=(root) NOPASSWD: /usr/sbin/jexec operator ALL=(root) NOPASSWD: /usr/sbin/jls
8.2. TRAMP Path Syntax
With tramp-default-proxies-alist configured, jail names auto-proxy
through the sandbox host:
;; Short form (auto-proxied through sandbox) C-x C-f /bastille:agent-alpha:/root/project/main.py ;; Explicit multi-hop (equivalent) C-x C-f /ssh:operator@sandbox|bastille:agent-alpha:/root/project/main.py ;; Dired inside jail C-x d /bastille:agent-alpha:/root/ghq/github.com/ ;; Edit host files (not jailed) C-x C-f /ssh:operator@sandbox:/etc/pf.conf
8.3. Helper Functions
(defun jail-find-file (jail path) "Open PATH inside Bastille JAIL on the sandbox host." (interactive (list (read-string "Jail: " "agent-alpha") (read-string "Path: " "/root/"))) (find-file (format "/bastille:%s:%s" jail path))) (defun jail-shell (jail) "Open a shell inside JAIL." (interactive (list (read-string "Jail: " "agent-alpha"))) (let ((default-directory (format "/bastille:%s:/root/" jail))) (shell (format "*jail:%s*" jail)))) (defun jail-compile (jail command) "Run COMMAND inside JAIL via compile." (interactive (list (read-string "Jail: " "agent-alpha") (read-string "Command: " "gmake check"))) (let ((default-directory (format "/bastille:%s:/root/" jail))) (compile command)))
8.4. Development Workflow
A typical session editing code inside a sandboxed jail:
- Open project.
M-x jail-find-filewith jailagent-alpha, path/root/ghq/forge.example/org/project/. TRAMP handles the multi-hop transparently. - Edit files. Syntax highlighting, completion, and LSP all work (provided the language server is installed inside the jail).
- Run tests.
M-x jail-compilewithgmake checkruns the test suite inside the jail. Errors appear in*compilation*with clickable file references that resolve through the TRAMP path. - Issue tracking.
M-x jail-shellthenbd readyto see available work.bd update <id> --status in_progressto claim tasks. - Git operations.
M-x vc-diffon TRAMP files works when git is available inside the jail.bd sync+git pushfrom the shell. - Copy results out. Use dired to copy build artifacts from the jail to the restricted host — TRAMP handles cross-hop copies.
8.5. Performance Notes
tramp-chunksizemust be 500 on FreeBSD (known bug).- Multi-hop adds latency. Keep restricted and sandbox hosts on the same switch (sub-1ms LAN).
ControlMasterin SSH config reduces connection overhead:
# ~/.ssh/config on restricted host # Use Tailscale IP, not .lan -- the .lan name may resolve differently # depending on your network, causing intermittent TRAMP failures. Host sandbox sandbox.example.ts.net HostName 100.x.y.z # Tailscale IP of sandbox host User operator IdentityFile ~/.ssh/id_ed25519 ControlMaster auto ControlPath ~/.ssh/cm-%r@%h:%p ControlPersist 10m ForwardAgent no
9. Conclusion
You don't need a cloud account or Kubernetes cluster to sandbox AI coding agents. Two commodity mini PCs, FreeBSD, and Bastille thin jails provide kernel-level isolation per agent identity with sub-second jail creation, ZFS snapshots for rollback, and pf firewall for egress control.
The key insight: the network is the real isolation boundary. Separating the machine where credentials live (restricted host) from the machine where agents execute (sandbox host with jails) limits blast radius in a way that no single-machine containerization can match.
The practical insight: --dangerously-skip-permissions becomes safe –
even appropriate – when the jail provides the containment that the flag
removes.