Practical Agent Sandbox Configurations: FreeBSD Jails and macOS Seatbelt

Table of Contents

The first section is an executive overview of the boundary as a control-flow shape, independent of technology. The rest of the document — FreeBSD jails, macOS Seatbelt, WASM sandboxing, pf rules, rctl limits — is the staff-engineer runbook. Read either or both; they share invariants but speak to different roles.

1. The boundary, independent of technology choice

Before any specific config, the shape of what the sandbox is doing on every invocation — independent of whether you pick a FreeBSD jail, a macOS sandbox-exec profile, a Linux user-namespace + seccomp combo, a microVM, or a container. The point of this section is that the control flow is the same; the four boundary checks fire in the same order, the error states have the same character, the protections sit in the same places. Technology choice is how; the diagram below is what.

Read top-to-bottom: an agent invocation enters the boundary; each axis (①–④) either lets it through or stops it. A stop is not a fallback to less-isolated behavior — it is a logged refusal that returns to the agent. The fifth gate is the egress chokepoint itself: even after the four boundary checks pass, the model call has to exit through a credential-injecting proxy you control.

cto-control-flow.png

The error sink is single and non-negotiable: a boundary that silently downgrades on failure is not a boundary, it is a polite suggestion. The egress proxy (⑤) is where the credential lives — not in the agent's environment — which makes it the smallest thing that can be revoked without touching the agent. The diagram is invariant under technology choice; map any platform's flow onto these five nodes before debating which tool to use.

1.1. Error states

Axis denied Fix lives at Exposure if missing
① compute host capability (jail / sandbox profile / container runtime) agent escapes the sandbox, runs as host user
② filesystem custody host filesystem policy (jail root, SBPL file-read*, container volume mounts) agent reads ~/.ssh, ~/.aws, .env, browser cookies
③ network egress host firewall (pf / iptables / nft / SBPL network-outbound) agent reaches the open internet, LAN services, internal APIs
④ secret custody proxy-only credential injection (proxy holds the key; agent gets an ephemeral handle or nothing) upstream key leaks via prompt, log, or training data
⑤ proxy fail-closed the proxy you run (LiteLLM, your own gateway, a managed gateway) one upstream incident silently takes the fleet down — or succeeds at unbounded cost

The right answer per row is one sentence about the enforcement point, not the policy. Policies drift; enforcement points are what can be audited. The rest of this document is the FreeBSD / macOS runbook that makes each enforcement point concrete.

2. Scope

This document contains tested and documented configurations for sandboxing AI coding agents on two platforms: FreeBSD 15.0 (production server "nexus") and macOS (development laptop "mini"). The companion notes Sandboxing AI Coding Agents with FreeBSD Jails cover the architecture and trust model; Agent Sandbox Architectures cover the decomposition of the sandbox concept across vendor offerings. This note is the runbook: exact commands, config files, and verification steps.

Every command is annotated: VERIFIED means tested on the running system; FROM DOCS means derived from official documentation but not yet executed in production.

3. FreeBSD 15.0 (nexus)

3.1. System inventory

Verified state of nexus as of 2026-06-19:

FreeBSD nexus 15.0-RELEASE  amd64
ZFS:           zroot, 335G used / 122G available
Bastille:      1.4.3.260429 (pkg, enabled in rc.conf)
pf:            enabled in rc.conf, rules loaded from /etc/pf.conf
VIMAGE:        kern.features.vimage=1 (VNET support compiled in)
RACCT/rctl:    present but DISABLED (kern.racct.enable=0)
bastille_zfs:  currently NO (needs enabling)
Network:       re0 at 192.168.86.100/24, gateway 192.168.86.1
Tailscale:     tailscale0 active
Java:          GraalVM 21.0.8 at ~/opt/graalvm-jdk-21.0.8+12.1
Clojure:       /usr/local/bin/clojure, /usr/local/bin/clj

3.2. One-time host preparation

3.2.1. Enable RACCT for rctl resource limits

FROM DOCS. RACCT is compiled into the GENERIC kernel on FreeBSD 15.0 but disabled at boot. Without it, rctl refuses to set limits.

# Add to /boot/loader.conf (requires reboot)
echo 'kern.racct.enable=1' | sudo tee -a /boot/loader.conf

# After reboot, verify:
sysctl kern.racct.enable
# kern.racct.enable: 1

The current system shows kern.racct.enable: 0. A reboot is required. Until then, rctl limits are unavailable – jails run without CPU/memory caps.

3.2.2. Enable ZFS-backed Bastille jails

FROM DOCS. Bastille defaults to bastille_zfs_enable"NO"=. Enabling it gives each jail its own ZFS dataset with snapshot/rollback/quota support.

# Edit /usr/local/etc/bastille/bastille.conf
sudo sysrc -f /usr/local/etc/bastille/bastille.conf \
    bastille_zfs_enable=YES \
    bastille_zfs_zpool=zroot

After this change, bastille create will create zroot/bastille/jails/<name> datasets automatically.

3.2.3. Bootstrap the 15.0-RELEASE base

FROM DOCS. This downloads ~200MB and creates the shared base system that thin jails mount read-only via NullFS.

sudo bastille bootstrap 15.0-RELEASE

This takes 20-30 seconds on a good connection. The result lands at /usr/local/bastille/releases/15.0-RELEASE/.

3.3. Jail creation: exact commands

3.3.1. Standard thin jail (no VNET)

FROM DOCS. A thin jail on a loopback alias. Cheapest option, shares the host's network stack. Good for agents that only need outbound HTTPS.

# Create loopback alias interface (one-time)
sudo sysrc cloned_interfaces+="lo1"
sudo sysrc ifconfig_lo1_name="bastille0"
sudo service netif cloneup

# Create the jail -- ~3 seconds with ZFS enabled
sudo bastille create agent-session-01 15.0-RELEASE 10.0.0.10

# Verify
sudo bastille list
sudo jls

The jail gets its own ZFS dataset at zroot/bastille/jails/agent-session-01 and NullFS-mounts the 15.0-RELEASE base read-only.

3.3.2. VNET jail with bridge networking

VERIFIED: VIMAGE is available (kern.features.vimage=1). FROM DOCS for the create commands.

VNET gives each jail its own network stack: separate IP, routing table, and firewall visibility. Required for per-jail egress filtering.

# Create a bridge interface (one-time)
sudo ifconfig bridge0 create
sudo ifconfig bridge0 addm re0 up
sudo sysrc cloned_interfaces+="bridge0"
sudo sysrc ifconfig_bridge0="addm re0 up"

# Create VNET jail attached to bridge
sudo bastille create -B agent-vnet-01 15.0-RELEASE 192.168.86.201/24 \
    -g 192.168.86.1

# -B: use bridge interface (if_bridge)
# -g: set default gateway
# IP must be on the same subnet as re0

# Verify the jail has its own network stack
sudo bastille cmd agent-vnet-01 ifconfig
# Should show epair interface, not re0

Bastille 1.4.3 names epair interfaces as e0a_jailname (host side) and e0b_jailname (jail side).

3.4. pf firewall rules for credential isolation

The goal: jails can reach the LiteLLM proxy and package mirrors, but nothing else. Particularly, they cannot reach the host's credential directories, other services on the LAN, or arbitrary internet hosts.

3.4.1. Current pf.conf

The existing /etc/pf.conf is minimal – just CNI NAT rules for containers:

v4egress_if = "ix0"
v6egress_if = "ix0"
nat on $v4egress_if inet from <cni-nat> to any -> ($v4egress_if)
...

Note: the config references ix0 but the actual egress interface is re0. This needs correcting.

3.4.2. Proposed pf.conf with jail egress control

FROM DOCS. Not yet loaded.

# /etc/pf.conf -- agent jail isolation
# Egress interface (verified: re0 on nexus)
ext_if = "re0"

# Jail network (VNET jails on bridge)
jail_net = "192.168.86.200/29"

# Loopback jail network (non-VNET jails)
jail_lo = "10.0.0.0/24"

# LiteLLM proxy address (on host or separate machine)
litellm_proxy = "192.168.86.100"
litellm_port = "4000"

# FreeBSD package mirror
pkg_mirrors = "{ pkg.freebsd.org, pkg0.nyi.freebsd.org, \
                 pkg0.isc.freebsd.org }"

# Forge hosts agents may reach
forge_hosts = "{ github.com, api.github.com, \
                 gitlab.com }"

set skip on lo0

# NAT for jail traffic going outbound
nat on $ext_if from ($jail_net) to any -> ($ext_if)
nat on $ext_if from ($jail_lo) to any -> ($ext_if)

# Existing CNI NAT rules
nat-anchor "cni-rdr/*"
rdr-anchor "cni-rdr/*"

# Default: block everything
block in all
block out all

# --- Host rules ---
# Allow host outbound (operator traffic)
pass out on $ext_if proto { tcp udp } from ($ext_if) to any

# Allow SSH to host from LAN
pass in on $ext_if proto tcp from 192.168.86.0/24 to ($ext_if) port 22

# --- Jail egress rules ---
# DNS (required for package installation and git)
pass out on $ext_if proto { tcp udp } from $jail_net to any port 53
pass out on $ext_if proto { tcp udp } from $jail_lo to any port 53

# LiteLLM proxy -- the ONLY path to model APIs
# Jails talk to the proxy; proxy talks to api.anthropic.com etc.
pass out proto tcp from $jail_net to $litellm_proxy port $litellm_port
pass out proto tcp from $jail_lo to $litellm_proxy port $litellm_port

# Package installation (HTTPS to FreeBSD mirrors)
pass out on $ext_if proto tcp from $jail_net to $pkg_mirrors port 443
pass out on $ext_if proto tcp from $jail_lo to $pkg_mirrors port 443

# Forge access (git clone/push over HTTPS and SSH)
pass out on $ext_if proto tcp from $jail_net to $forge_hosts port { 22 443 }
pass out on $ext_if proto tcp from $jail_lo to $forge_hosts port { 22 443 }

# --- Block credential exfiltration ---
# Jails CANNOT reach:
# - Host SSH (port 22 on host IP)
# - Other LAN machines
# - Arbitrary internet hosts
# - Host nREPL, Prometheus, Grafana ports
block out log proto tcp from $jail_net to $litellm_proxy port { 22 3000 9090 7888 }
block out log proto tcp from $jail_lo to $litellm_proxy port { 22 3000 9090 7888 }

# Allow inter-jail on bastille0 (for jail-to-jail if needed)
pass on bastille0 all

Key design decisions:

  • Jails reach model APIs only through the LiteLLM proxy on port 4000. They never see ANTHROPIC_API_KEY or OPENAI_API_KEY.
  • Forge access is limited to named hosts. An agent cannot curl evil.com to exfiltrate code.
  • Host services (SSH, Grafana, Prometheus, nREPL) are explicitly blocked from jail networks.

Load and verify:

# Syntax check (does not load)
sudo pfctl -n -f /etc/pf.conf

# Load rules
sudo pfctl -f /etc/pf.conf

# Verify rules loaded
sudo pfctl -sr | head -30

# Test from inside a jail
sudo bastille cmd agent-vnet-01 fetch -qo /dev/null https://github.com
# Should succeed (forge host allowed)

sudo bastille cmd agent-vnet-01 fetch -qo /dev/null https://evil.com
# Should fail (not in allowlist)

3.5. rctl resource limits per jail

FROM DOCS. Requires kern.racct.enable=1 (see one-time preparation).

# Memory: deny allocations beyond 2GB
sudo rctl -a jail:agent-session-01:memoryuse:deny=2G

# Virtual memory: deny beyond 4GB (includes shared libs)
sudo rctl -a jail:agent-session-01:vmemoryuse:deny=4G

# Max processes: deny beyond 100
sudo rctl -a jail:agent-session-01:maxproc:deny=100

# Open files: deny beyond 4096
sudo rctl -a jail:agent-session-01:openfiles:deny=4096

# CPU: throttle to 50% of one core
sudo rctl -a jail:agent-session-01:pcpu:deny=50

# Wall clock time: signal after 4 hours (prevent runaway sessions)
sudo rctl -a jail:agent-session-01:wallclock:sigterm=14400

# View current limits
sudo rctl -l jail:agent-session-01

# View current usage
sudo rctl -hu jail:agent-session-01

For persistent limits across reboots, add to /etc/rctl.conf:

# /etc/rctl.conf
jail:agent-session-01:memoryuse:deny=2G
jail:agent-session-01:vmemoryuse:deny=4G
jail:agent-session-01:maxproc:deny=100
jail:agent-session-01:openfiles:deny=4096
jail:agent-session-01:pcpu:deny=50
jail:agent-session-01:wallclock:sigterm=14400

Bastille also supports limits via its template system:

# In a Bastillefile
LIMITS memoryuse:deny=2G
LIMITS vmemoryuse:deny=4G
LIMITS maxproc:deny=100

3.6. ZFS dataset per jail

3.6.1. Automatic (with bastille_zfs_enable=YES)

When ZFS is enabled in Bastille config, each bastille create automatically creates a dataset:

zroot/bastille/jails/agent-session-01       # jail root
zroot/bastille/jails/agent-session-01/root  # jail filesystem

3.6.2. Quota

# Set 5GB quota per jail
sudo zfs set quota=5G zroot/bastille/jails/agent-session-01/root

# Verify
sudo zfs get quota zroot/bastille/jails/agent-session-01/root

3.6.3. Snapshot and rollback

# Pre-task snapshot
sudo zfs snapshot zroot/bastille/jails/agent-session-01/root@pre-task

# View what changed since snapshot
sudo zfs diff zroot/bastille/jails/agent-session-01/root@pre-task

# Rollback (requires stopping the jail)
sudo bastille stop agent-session-01
sudo zfs rollback zroot/bastille/jails/agent-session-01/root@pre-task
sudo bastille start agent-session-01

# Clean up snapshot when no longer needed
sudo zfs destroy zroot/bastille/jails/agent-session-01/root@pre-task

3.6.4. Compression

Bastille's default ZFS options include compression:

bastille_zfs_options="-o compress=on -o atime=off"

This is applied automatically when creating jails with ZFS enabled.

3.7. Mounting site/ read-only into a jail

NullFS mounts allow sharing host directories into jails. Read-only mounts prevent the agent from modifying the source tree on the host.

3.7.1. Via bastille mount

FROM DOCS.

# Mount host site/ directory into jail at /mnt/site (read-only)
sudo bastille mount agent-session-01 \
    /home/jwalsh/ghq/github.com/jwalsh/www.wal.sh/site \
    /mnt/site nullfs ro 0 0

# Verify from inside jail
sudo bastille cmd agent-session-01 ls /mnt/site/index.org

# The agent can read but not write:
sudo bastille cmd agent-session-01 touch /mnt/site/test
# touch: /mnt/site/test: Read-only file system

This creates an entry in the jail's fstab at /usr/local/bastille/jails/agent-session-01/fstab.

3.7.2. Via Bastillefile template

# In Bastillefile
MOUNT /home/jwalsh/ghq/github.com/jwalsh/www.wal.sh/site /mnt/site nullfs ro 0 0

3.7.3. Read-write working copy pattern

If the agent needs to modify files, mount a ZFS clone instead:

# Snapshot the host data
sudo zfs snapshot zroot/home/jwalsh@agent-work

# Clone it (cheap, copy-on-write)
sudo zfs clone zroot/home/jwalsh@agent-work \
    zroot/bastille/jails/agent-session-01/root/mnt/work

# Agent can write to /mnt/work without affecting the original
# When done, diff and cherry-pick changes
sudo zfs diff zroot/home/jwalsh@agent-work

3.8. Running nREPL inside a jail

The Clojure REPL inside a jail provides site search and data lookup without granting the agent direct host filesystem access.

3.8.1. Installing Java and Clojure in a jail

FROM DOCS.

# Install OpenJDK and Clojure inside the jail
sudo bastille pkg agent-session-01 install -y openjdk21 clojure rlwrap

# Set JAVA_HOME inside the jail
sudo bastille cmd agent-session-01 \
    sh -c 'echo "export JAVA_HOME=/usr/local/openjdk21" >> /root/.bashrc'
sudo bastille cmd agent-session-01 \
    sh -c 'echo "export PATH=\$JAVA_HOME/bin:\$PATH" >> /root/.bashrc'

3.8.2. Starting nREPL

# Copy deps.edn into jail (or mount project read-only)
sudo cp /home/jwalsh/ghq/github.com/jwalsh/www.wal.sh/deps.edn \
    /usr/local/bastille/jails/agent-session-01/root/root/deps.edn

# Start nREPL inside jail, bound to jail's IP
sudo bastille cmd agent-session-01 \
    sh -c 'cd /root && clojure -Sdeps "{:deps {nrepl/nrepl {:mvn/version \"1.3.0\"}}}" \
           -M -m nrepl.cmdline --bind 0.0.0.0 --port 7888'

3.8.3. Sharing nREPL port from jail to host

For non-VNET jails (loopback alias), the jail's IP (e.g., 10.0.0.10) is reachable from the host:

# From host, connect to jail's nREPL
clojure -Sdeps '{:deps {nrepl/nrepl {:mvn/version "1.3.0"}}}' \
    -M -m nrepl.cmdline --connect --host 10.0.0.10 --port 7888

For VNET jails on the bridge network (e.g., 192.168.86.201):

# Directly reachable since jail is on the same bridge as re0
clojure -Sdeps '{:deps {nrepl/nrepl {:mvn/version "1.3.0"}}}' \
    -M -m nrepl.cmdline --connect --host 192.168.86.201 --port 7888

For Emacs CIDER from the restricted host:

;; Connect CIDER to jail's nREPL
(cider-connect '(:host "10.0.0.10" :port 7888))

;; Or with TRAMP (via Bastille method):
;; M-x cider-connect-clj with host=10.0.0.10, port=7888

If pf blocks the nREPL port, add a pass rule:

# Allow host to reach jail nREPL (add to pf.conf)
pass in proto tcp from 192.168.86.0/24 to $jail_net port 7888
pass in proto tcp from 192.168.86.0/24 to $jail_lo port 7888

3.9. Bastille template for automated agent jail provisioning

A template automates the full agent jail setup: packages, shell config, credential directories, resource limits, and mount points.

3.9.1. Template directory structure

agent-session/
  Bastillefile
  root/
    root/
      .bashrc
      .profile

3.9.2. Bastillefile

FROM DOCS. Based on Bastille template syntax from 1.4.x docs.

# agent-session/Bastillefile
# Bastille template for AI coding agent jail provisioning
#
# Usage:
#   sudo bastille template agent-session-01 /path/to/agent-session \
#       --arg LITELLM_HOST=192.168.86.100 \
#       --arg LITELLM_PORT=4000 \
#       --arg AGENT_NAME=agent-alpha

ARG LITELLM_HOST=192.168.86.100
ARG LITELLM_PORT=4000
ARG AGENT_NAME=agent

# --- Base system configuration ---
CMD touch /etc/rc.conf
CMD pw user mod root -h -

SYSRC sendmail_enable="NO"
SYSRC sendmail_submit_enable="NO"
SYSRC sendmail_outbound_enable="NO"
SYSRC sendmail_msp_queue_enable="NO"
SYSRC syslogd_flags="-ss"
SYSRC cron_flags="-J 60"

# --- Required packages ---
# bash and gmake are REQUIRED for Claude Code on FreeBSD
PKG bash gmake git gh ghq curl
PKG node npm-node22
PKG openjdk21 clojure rlwrap
PKG python311 py311-pip

# --- Shell configuration ---
# Claude Code requires bash; FreeBSD jails default to /bin/sh
CMD chsh -s /usr/local/bin/bash root

# --- Copy shell config files ---
CP root

# --- Install Claude Code ---
CMD npm install -g @anthropic-ai/claude-code

# --- Create credential directories ---
CMD mkdir -p /root/.config/gh
CMD mkdir -p /root/.claude
CMD mkdir -p /root/.ssh
CMD chmod 700 /root/.ssh /root/.claude

# --- Configure LiteLLM proxy as model endpoint ---
CMD echo "export ANTHROPIC_BASE_URL=http://${LITELLM_HOST}:${LITELLM_PORT}" >> /root/.bashrc
CMD echo "export OPENAI_BASE_URL=http://${LITELLM_HOST}:${LITELLM_PORT}" >> /root/.bashrc

# --- Set agent identity ---
CMD echo "export AGENT_NAME=${AGENT_NAME}" >> /root/.bashrc

# --- Resource limits (requires kern.racct.enable=1) ---
LIMITS memoryuse:deny=2G
LIMITS vmemoryuse:deny=4G
LIMITS maxproc:deny=100
LIMITS openfiles:deny=4096

# --- Mount host site/ read-only (operator fills in FSTAB separately) ---
# MOUNT requires absolute paths set at apply time

# --- Tags for management ---
TAGS agent-session ai-coding

3.9.3. Shell config files

# agent-session/root/root/.bashrc
export SHELL=/usr/local/bin/bash
export LANG=en_US.UTF-8
export EDITOR=vi
export JAVA_HOME=/usr/local/openjdk21
export PATH=$JAVA_HOME/bin:$HOME/.npm-global/bin:$PATH

# LiteLLM proxy (set by template ARG)
# ANTHROPIC_BASE_URL and OPENAI_BASE_URL set above

# Aliases
alias make=gmake
alias ll='ls -la'
alias claude='claude --dangerously-skip-permissions'

# Prompt shows jail name
PS1='[\u@jail:\h \W]\$ '
# agent-session/root/root/.profile
export SHELL=/usr/local/bin/bash
[ -f ~/.bashrc ] && . ~/.bashrc

3.9.4. Applying the template

# Create jail first
sudo bastille create agent-session-01 15.0-RELEASE 10.0.0.10

# Apply template
sudo bastille template agent-session-01 /path/to/agent-session \
    --arg LITELLM_HOST=192.168.86.100 \
    --arg LITELLM_PORT=4000 \
    --arg AGENT_NAME=agent-alpha

# Copy credentials (manual -- never in template)
sudo cp /home/agents/alpha/.config/gh/hosts.yml \
    /usr/local/bastille/jails/agent-session-01/root/root/.config/gh/
sudo cp /home/agents/alpha/.claude/.credentials.json \
    /usr/local/bastille/jails/agent-session-01/root/root/.claude/

# Mount site read-only
sudo bastille mount agent-session-01 \
    /home/jwalsh/ghq/github.com/jwalsh/www.wal.sh/site \
    /mnt/site nullfs ro 0 0

# Set ZFS quota
sudo zfs set quota=5G zroot/bastille/jails/agent-session-01/root

# Start the jail
sudo bastille start agent-session-01

3.9.5. Full lifecycle script

#!/bin/sh
# jail-agent-session.sh -- create, provision, and start an agent jail
# Usage: ./jail-agent-session.sh <name> <ip> [litellm-host] [litellm-port]

set -e

NAME="${1:?Usage: $0 <name> <ip> [litellm-host] [litellm-port]}"
IP="${2:?Usage: $0 <name> <ip> [litellm-host] [litellm-port]}"
LITELLM_HOST="${3:-192.168.86.100}"
LITELLM_PORT="${4:-4000}"
TEMPLATE_DIR="/usr/local/bastille/templates/agent-session"
SITE_DIR="/home/jwalsh/ghq/github.com/jwalsh/www.wal.sh/site"

echo "Creating jail: $NAME at $IP"
bastille create "$NAME" 15.0-RELEASE "$IP"

echo "Applying template..."
bastille template "$NAME" "$TEMPLATE_DIR" \
    --arg LITELLM_HOST="$LITELLM_HOST" \
    --arg LITELLM_PORT="$LITELLM_PORT" \
    --arg AGENT_NAME="$NAME"

echo "Setting ZFS quota..."
zfs set quota=5G "zroot/bastille/jails/${NAME}/root"

echo "Mounting site read-only..."
bastille mount "$NAME" "$SITE_DIR" /mnt/site nullfs ro 0 0

echo "Taking baseline snapshot..."
zfs snapshot "zroot/bastille/jails/${NAME}/root@baseline"

echo "Starting jail..."
bastille start "$NAME"

echo "Jail $NAME ready at $IP"
echo "  Console: bastille console $NAME"
echo "  Run:     bastille cmd $NAME claude --print 'hello'"

4. macOS (mini, development)

4.1. sandbox-exec and Seatbelt profiles

Apple's sandbox-exec applies a Seatbelt profile to a process. It is deprecated by Apple but still present and functional on all current macOS versions. Claude Code's built-in sandbox uses it on macOS.

4.1.1. sandbox-exec syntax

# Apply a named built-in profile
sandbox-exec -n no-internet /bin/bash

# Apply a profile from a file
sandbox-exec -f /path/to/profile.sb /usr/bin/curl https://example.com

# Apply an inline profile string
sandbox-exec -p '(version 1)(deny default)(allow process-exec)' /bin/ls

# Pass parameters to a profile
sandbox-exec -D HOME=/Users/jwalsh -f profile.sb /bin/bash

4.1.2. Example Seatbelt profile for agent isolation

FROM DOCS. Seatbelt profiles use a Scheme-like syntax. Apple stopped documenting the language but it remains functional.

;; agent-sandbox.sb -- restrict an agent process
(version 1)

;; Start by denying everything
(deny default)

;; Allow the process to execute
(allow process-exec)
(allow process-fork)

;; Allow reading system libraries and frameworks
(allow file-read*
    (subpath "/usr/lib")
    (subpath "/usr/share")
    (subpath "/System")
    (subpath "/Library/Frameworks")
    (subpath "/usr/local/lib")
    (subpath "/opt/homebrew"))

;; Allow reading the project directory
(allow file-read*
    (subpath "/Users/jwalsh/ghq/github.com/jwalsh/www.wal.sh"))

;; Allow writing ONLY to the project directory and /tmp
(allow file-write*
    (subpath "/Users/jwalsh/ghq/github.com/jwalsh/www.wal.sh")
    (subpath "/tmp")
    (subpath "/private/tmp"))

;; Allow reading home directory configs (read-only)
(allow file-read*
    (subpath "/Users/jwalsh/.claude")
    (subpath "/Users/jwalsh/.config"))

;; DENY reading credentials
(deny file-read*
    (subpath "/Users/jwalsh/.ssh")
    (subpath "/Users/jwalsh/.aws")
    (subpath "/Users/jwalsh/.gnupg")
    (literal "/Users/jwalsh/.netrc"))

;; Network: allow only specific hosts via localhost proxy
;; (The sandbox-runtime handles this via proxy)
(allow network-outbound
    (remote tcp "localhost:4000"))  ;; LiteLLM proxy only

;; Deny all other network
(deny network-outbound)
(deny network-inbound)

;; Allow mach lookups for basic system services
(allow mach-lookup
    (global-name "com.apple.system.logger")
    (global-name "com.apple.SecurityServer"))

;; Allow sysctl reads (needed by Node.js)
(allow sysctl-read)

Run an agent under this profile:

sandbox-exec -f agent-sandbox.sb \
    -D HOME=/Users/jwalsh \
    /opt/homebrew/bin/node /opt/homebrew/bin/claude --print "hello"

Note: this is fragile in practice. Node.js and Claude Code access many system paths that are hard to enumerate. The sandbox-runtime package (below) handles this automatically.

4.2. Claude Code's built-in sandbox

Claude Code ships a built-in sandbox activated with /sandbox in a session or via settings. On macOS it uses Seatbelt; on Linux it uses bubblewrap.

4.2.1. Enabling via settings

{
  "sandbox": {
    "enabled": true,
    "filesystem": {
      "allowWrite": ["~/.kube", "/tmp/build"],
      "denyRead": ["~/.ssh", "~/.aws", "~/.gnupg"]
    },
    "network": {
      "allowedDomains": [
        "api.anthropic.com",
        "github.com",
        "*.github.com",
        "registry.npmjs.org",
        "pkg.freebsd.org"
      ]
    }
  }
}

Place in ~/.claude/settings.json (user-wide) or .claude/settings.json (project).

4.2.2. Sandbox modes

Two modes, selected via /sandbox in a session:

  • Auto-allow: sandboxed commands run without prompting. Commands that fail sandbox restrictions fall back to the regular permission flow.
  • Regular permissions: all commands go through permission prompts, but the sandbox still enforces filesystem/network restrictions at the OS level.

4.2.3. Strict mode (for unattended operation)

{
  "sandbox": {
    "enabled": true,
    "failIfUnavailable": true,
    "allowUnsandboxedCommands": false
  }
}

failIfUnavailable=true blocks Claude Code from starting if the sandbox cannot initialize. allowUnsandboxedCommands=false disables the dangerouslyDisableSandbox escape hatch entirely.

4.2.4. Managed settings for organizations

Deliver sandbox configuration via MDM or server-managed settings. Managed settings override user settings for boolean keys; array keys (like allowedDomains) are merged.

To prevent developers from widening read access:

{
  "sandbox": {
    "enabled": true,
    "allowManagedReadPathsOnly": true,
    "allowManagedDomainsOnly": true,
    "filesystem": {
      "denyRead": ["~/.ssh", "~/.aws"]
    },
    "network": {
      "allowedDomains": ["api.anthropic.com"]
    }
  }
}

4.2.5. Security limitations of the built-in sandbox

From the official docs:

  • Network filtering: the built-in proxy does not terminate or inspect TLS. Domain fronting can bypass the allowlist.
  • Default read access: by default, read access includes the entire computer including ~/.aws/credentials and ~/.ssh/. Must explicitly add these to denyRead.
  • Unix sockets: allowUnixSockets can grant access to powerful system services (e.g., Docker socket).
  • Scope: only Bash commands are sandboxed. Built-in Read/Edit/Write tools use the permission system, not the sandbox.

4.3. Anthropic's sandbox-runtime package

The @anthropic-ai/sandbox-runtime package wraps an entire process in Seatbelt (macOS) or bubblewrap (Linux) isolation. Unlike the built-in Bash sandbox, this constrains all tools, hooks, and MCP servers in the session.

4.3.1. Installation

npm install -g @anthropic-ai/sandbox-runtime

4.3.2. Configuration

Create ~/.srt-settings.json:

{
  "network": {
    "allowedDomains": [
      "api.anthropic.com",
      "github.com",
      "*.github.com",
      "registry.npmjs.org"
    ],
    "deniedDomains": [],
    "allowLocalBinding": false
  },
  "filesystem": {
    "denyRead": ["~/.ssh", "~/.aws", "~/.gnupg"],
    "allowRead": ["."],
    "allowWrite": [
      ".",
      "/tmp",
      "~/.claude",
      "~/.claude.json"
    ],
    "denyWrite": [".env", "config/production.json"]
  }
}

4.3.3. Usage

# Wrap Claude Code in the sandbox
npx @anthropic-ai/sandbox-runtime claude

# Or directly with srt
srt claude

# With custom settings
srt --settings /path/to/strict-settings.json claude

# Wrap an MCP server
srt npx -y @modelcontextprotocol/server-filesystem

4.3.4. How it works internally

On macOS: dynamically generates a Seatbelt profile from the settings and calls sandbox-exec with it. Network traffic routes through a proxy (HTTP proxy + SOCKS5 proxy) running outside the sandbox.

On Linux: creates isolated namespaces via bubblewrap. Network namespace is removed entirely; traffic routes through Unix domain sockets to the proxy on the host.

4.3.5. LiteLLM proxy integration

For credential isolation with a local LiteLLM proxy:

{
  "network": {
    "allowedDomains": ["localhost"],
    "allowLocalBinding": false
  },
  "filesystem": {
    "denyRead": ["~/.ssh", "~/.aws", "~/.gnupg",
                 "~/.anthropic", "~/.config/openai"],
    "allowWrite": [".", "/tmp", "~/.claude"]
  }
}

The agent's environment sets ANTHROPIC_BASE_URL=http://localhost:4000. The sandbox allows outbound to localhost only. The LiteLLM proxy holds the real API keys and forwards requests to api.anthropic.com.

4.4. Network proxy for credential isolation

4.4.1. LiteLLM as the egress chokepoint

LiteLLM runs on the host (outside the sandbox). Agents inside the sandbox see only the proxy URL, never the real API keys.

# litellm_config.yaml
model_list:
  - model_name: claude-sonnet-4-20250514
    litellm_params:
      model: anthropic/claude-sonnet-4-20250514
      api_key: sk-ant-...  # real key, only in proxy config
  - model_name: gpt-4o
    litellm_params:
      model: openai/gpt-4o
      api_key: sk-...      # real key, only in proxy config

general_settings:
  master_key: "sk-litellm-master-key"  # proxy auth
  spend_tracking: true
  budget_duration: "1d"
  max_budget: 10.0  # $10/day per key

Start:

litellm --config litellm_config.yaml --host 0.0.0.0 --port 4000

4.4.2. Agent-side configuration (inside sandbox or jail)

export ANTHROPIC_BASE_URL=http://192.168.86.100:4000
export ANTHROPIC_API_KEY=sk-litellm-master-key
# Agent calls go to proxy -> proxy calls real API with real key

The agent's ANTHROPIC_API_KEY is the LiteLLM master key – a proxy authentication token, not the real Anthropic key. Even if exfiltrated, it only works against the proxy, which has budget limits and can be rotated instantly.

5. Red teaming: testing sandbox escape

5.1. FreeBSD jail escape tests

A verification protocol for newly provisioned jails. Run from the host.

#!/bin/sh
# jail-redteam.sh <jail-name>
JAIL=$1
PASS=0; FAIL=0

check() {
    desc="$1"; shift
    if "$@" >/dev/null 2>&1; then
        echo "FAIL (escaped): $desc"
        FAIL=$((FAIL + 1))
    else
        echo "PASS (blocked): $desc"
        PASS=$((PASS + 1))
    fi
}

check_succeeds() {
    desc="$1"; shift
    if "$@" >/dev/null 2>&1; then
        echo "PASS (works): $desc"
        PASS=$((PASS + 1))
    else
        echo "FAIL (broken): $desc"
        FAIL=$((FAIL + 1))
    fi
}

echo "=== Jail Red Team: $JAIL ==="
echo ""

echo "--- Process isolation ---"
count=$(sudo bastille cmd "$JAIL" ps aux 2>&1 | wc -l | tr -d ' ')
if [ "$count" -lt 15 ]; then
    echo "PASS: only $count process lines visible (jail-scoped)"
    PASS=$((PASS + 1))
else
    echo "FAIL: $count process lines visible (host leaking)"
    FAIL=$((FAIL + 1))
fi

echo ""
echo "--- Filesystem isolation ---"
check "Read host /home" sudo bastille cmd "$JAIL" ls /home/jwalsh
check "Read host /root" sudo bastille cmd "$JAIL" ls /root/.ssh/id_ed25519
check "Read Bastille dir" sudo bastille cmd "$JAIL" ls /usr/local/bastille
check "Write to NullFS base" sudo bastille cmd "$JAIL" touch /bin/test-escape
check "Mount filesystem" sudo bastille cmd "$JAIL" mount -t tmpfs tmpfs /mnt

echo ""
echo "--- Network isolation ---"
check "Raw sockets (ping)" sudo bastille cmd "$JAIL" ping -c1 127.0.0.1
check "Reach host SSH" sudo bastille cmd "$JAIL" \
    fetch -qo /dev/null -T5 http://192.168.86.100:22
check "Reach arbitrary host" sudo bastille cmd "$JAIL" \
    fetch -qo /dev/null -T5 https://evil.example.com
check "Reach host Grafana" sudo bastille cmd "$JAIL" \
    fetch -qo /dev/null -T5 http://192.168.86.100:3000

echo ""
echo "--- Credential isolation ---"
check "Read host SSH keys" sudo bastille cmd "$JAIL" cat /home/jwalsh/.ssh/id_ed25519
check "Read host .env" sudo bastille cmd "$JAIL" cat /home/jwalsh/.env
check "Access real API key" sudo bastille cmd "$JAIL" \
    sh -c 'env | grep -i "sk-ant-"'

echo ""
echo "--- Allowed operations ---"
check_succeeds "DNS resolution" sudo bastille cmd "$JAIL" host github.com
check_succeeds "LiteLLM proxy" sudo bastille cmd "$JAIL" \
    fetch -qo /dev/null http://192.168.86.100:4000/health
check_succeeds "Git available" sudo bastille cmd "$JAIL" git --version

echo ""
echo "Results: $PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ] && echo "ALL CLEAR" || echo "SECURITY ISSUES DETECTED"

5.2. Known jail escape vectors

A 2025 security audit (cited in Vermaden's analysis) found ~50 kernel vulnerabilities that enabled jail escapes over FreeBSD's history. Current practical vectors:

Vector Status Mitigation
Kernel vulnerability Ongoing risk Keep FreeBSD patched (freebsd-update)
Shared /dev access Mitigated devfs_ruleset restricts device access
Shared SysV IPC Mitigated Disabled by default in jails
Ptrace between jails Blocked Kernel prevents cross-jail ptrace
Chroot within jail Allowed Not an escape vector
Privileged sysctl writes Blocked Most sysctls read-only in jails

The network bulkhead (jails cannot reach the restricted host where real credentials live) is the primary security boundary, not the jail kernel boundary. A jail escape on the sandbox host yields access to a machine with no credentials worth stealing.

5.3. macOS sandbox escape testing

# Test Seatbelt profile enforcement
# Run each inside: sandbox-exec -f agent-sandbox.sb /bin/sh -c '...'

# File read denied
sandbox-exec -f agent-sandbox.sb /bin/sh -c 'cat ~/.ssh/id_ed25519'
# Expected: Operation not permitted

# File write denied outside project
sandbox-exec -f agent-sandbox.sb /bin/sh -c 'touch ~/Desktop/escape-test'
# Expected: Operation not permitted

# Network denied
sandbox-exec -f agent-sandbox.sb /bin/sh -c 'curl https://evil.com'
# Expected: connection refused / denied

# Process visibility
sandbox-exec -f agent-sandbox.sb /bin/sh -c 'ps aux | wc -l'
# Note: Seatbelt does NOT isolate process namespace (unlike jails)

Key difference from FreeBSD: macOS Seatbelt does not provide process namespace isolation. A sandboxed process can see all host processes via ps. For full process isolation on macOS, use a VM (Docker Desktop sandboxes use Virtualization.framework).

6. Performance overhead

6.1. FreeBSD jails vs bare metal

Jails add negligible overhead for most workloads. The jail boundary is enforced at the kernel level, not through emulation.

Metric Bare metal Thin jail Overhead
Process creation baseline ~same <1%
File I/O (local) baseline ~same <1%
File I/O (NullFS mount) baseline ~same <2%
Network throughput baseline ~same (shared stack) <1%
Network throughput (VNET) baseline slight overhead 2-5%
Memory (base) 0 ~0 (shared via NullFS) Negligible
Memory (per-jail delta) 0 ~50-100MB (pkg metadata) Per jail
Jail creation time N/A ~3 seconds (thin, ZFS) N/A
ZFS snapshot N/A <1 second N/A

The only measurable overhead is VNET networking, where the epair bridge adds 2-5% latency compared to the host's direct interface. For agent workloads (which are I/O-bound waiting on model API responses), this is imperceptible.

6.2. macOS Seatbelt overhead

Claude Code's docs: "minimal, but some filesystem operations may be slightly slower." In practice, the Seatbelt check on each syscall adds microseconds – irrelevant for agent workloads where a single model API call takes 1-30 seconds.

6.3. sandbox-runtime overhead

The proxy-based network isolation adds one hop per outbound connection. For model API calls (large payloads, high latency), the proxy overhead is negligible. For rapid small requests (npm install with many packages), the proxy can add noticeable latency.

7. Configuration comparison

Feature FreeBSD Jails macOS Seatbelt sandbox-runtime
Process isolation Full (kernel namespace) None (process visible) None
Filesystem isolation Full (separate root) Profile-based allow/deny Profile-based allow/deny
Network isolation pf firewall per-jail Profile + proxy Proxy-based
Resource limits rctl (CPU, mem, proc) None None
Snapshots / rollback ZFS native None None
Credential isolation Separate filesystem denyRead paths denyRead paths
Startup time ~3 seconds Instant Instant
Persistence Full (ZFS dataset) Host filesystem Host filesystem
Multi-tenant Yes (many jails) No No
Platform FreeBSD only macOS only macOS + Linux

8. Related notes

9. Hydra testing session (2026-06-19)

Testing on hydra (FreeBSD 14.3) confirmed:

  • Podman on FreeBSD: not viable. Uses Linux emulation layer, significantly slower than native. See gist:8e38dfd3efeb1f2205616d02ee6b8374.
  • Bastille jails: correct path on FreeBSD. bastille create claude-sandbox 14.3-RELEASE 10.20.30.5 works.
  • LiteLLM proxy: functional from hydra → mini (192.168.86.22:4000). Requires API key header even for Ollama models (by design: the proxy authenticates the caller, not just the backend).

Source: hydra sandbox testing gist

10. WASM-based sandboxing for agent tool execution (2026-06-19)

An alternative to OS-level isolation: run agent-generated code inside a WebAssembly sandbox. WASM provides a memory-safe, capability-based execution environment where the host explicitly grants every resource. No filesystem, no network, no syscalls unless the host provides them.

The question: which WASM tools actually run on FreeBSD 15.0?

10.1. Inventory of tools tested

Tool FreeBSD pkg Installed Status RSS
Deno 2.6.6 deno-2.6.6_2 Yes VERIFIED 77MB
wasmtime 34.0.2 via cargo Yes (CLI) VERIFIED 22MB
wasmtime 43.0.0 wasmtime-43.0.0_1 Available Not installed  
wasmer 6.1.0 wasmer-6.1.0_4 Yes VERIFIED  
Pyodide (Node.js) via npm Yes VERIFIED 226MB
MicroPython WASM pip install micropython-wasm Binary only PARTIAL  
QuickJS quickjs-2025.09.13 Available Not installed  
QuickJS-NG quickjs-ng-0.13.0 Available Not installed  

10.2. Deno: the practical winner

Deno 2.6.6 runs natively on FreeBSD 15.0 via pkg install deno. Its permission model maps directly to the four sandbox axes from the decomposition in Agent Sandbox Architectures:

Sandbox axis Deno flag Tested
Filesystem read --allow-read=site/ VERIFIED
Filesystem write --allow-write=/tmp --deny-write.ssh= VERIFIED
Network egress --allow-net=192.168.86.22:4000 VERIFIED
Subprocess (denied by default) VERIFIED
Environment (denied by default) VERIFIED

10.2.1. Permission tests (verified on nexus, 2026-06-19)

Network scoping to LiteLLM proxy:

$ echo 'const r = await fetch("http://192.168.86.22:4000/v1/models");
  console.log(r.status)' | deno run --allow-net=192.168.86.22:4000 -
401       # ← reached proxy (auth required, as expected)

$ echo 'await fetch("https://example.com")' \
  | deno run --allow-net=192.168.86.22:4000 -
error: Requires net access to "example.com:443"

Filesystem scoping:

$ deno run --allow-read=site/ test.ts
site entry: books.org
READ site/ - OK
READ .ssh - BLOCKED: Requires read access to "/home/jwalsh/.ssh"
READ .aws - BLOCKED: Requires read access to "/home/jwalsh/.aws"
READ /etc/passwd - BLOCKED: Requires read access to "/etc/passwd"

Write deny takes precedence over allow:

$ deno run --allow-write=/tmp --deny-write=.ssh test.ts
WRITE /tmp/ - OK (allowed)
WRITE .ssh - BLOCKED: Requires write access to "/home/jwalsh/.ssh/evil-key"
SUBPROCESS - BLOCKED: Requires run access to "id"
ENV - BLOCKED: Requires env access to "HOME"

Zero-permission mode (maximum sandbox – no flags):

$ deno run /tmp/sandbox-test-noperm.ts
read /etc/passwd: BLOCKED
write /tmp: BLOCKED
net: BLOCKED
run: BLOCKED
env: BLOCKED

10.2.2. Permission broker via Unix socket

A centralized policy broker can gate agent tool calls. The broker runs outside the sandbox; sandboxed code asks it for permission before acting.

$ deno run --allow-read --allow-write=/tmp --allow-net --unstable-net broker.ts &
Broker listening on /tmp/deno-policy-broker.sock

$ deno run --allow-read=/tmp --allow-write=/tmp --unstable-net client.ts
Policy request: {"action":"fetch","url":"http://192.168.86.22:4000/v1/models"}
Policy response: { allowed: true, reason: "LiteLLM proxy" }

VERIFIED on FreeBSD 15.0. Unix domain sockets work correctly with Deno's --unstable-net flag.

10.2.3. Daily audit use case

Sandboxed style checking of org-mode content. The evaluator receives untrusted code via stdin; Deno runs it with zero permissions.

// sandbox-eval.ts -- run with: deno run sandbox-eval.ts
const buf = new Uint8Array(65536);
const n = await Deno.stdin.read(buf);
const code = new TextDecoder().decode(buf.subarray(0, n!));
try {
  const result = new Function(code)();
  console.log(JSON.stringify({ ok: true, result: String(result) }));
} catch (e) {
  console.log(JSON.stringify({ ok: false, error: (e as Error).message }));
}
$ echo 'return 2 + 2' | deno run sandbox-eval.ts
{"ok":true,"result":"4"}

$ echo 'return Deno.readTextFile("/etc/passwd")' | deno run sandbox-eval.ts
error: Requires read access to "/etc/passwd"

$ echo 'return fetch("https://example.com")' | deno run sandbox-eval.ts
error: Requires net access to "example.com:443"

Memory overhead: 77MB RSS for a simple eval. Startup: 60ms.

10.3. wasmtime: fuel-limited WASM execution

wasmtime runs on FreeBSD 15.0 via cargo install wasmtime-cli (currently v34.0.2) or pkg install wasmtime (v43.0.0). The CLI provides fuel (instruction budget), timeouts, and memory caps.

10.3.1. Fuel (CPU budget)

$ wasmtime run -W fuel=10000000 loop.wat
(completed)

$ wasmtime run -W fuel=50 loop.wat
wasm trap: all fuel consumed by WebAssembly

10.3.2. Resource limits

$ wasmtime run -W timeout=100ms loop.wat       # wall-clock timeout
$ wasmtime run -W max-memory-size=1048576 ...  # 1MB memory cap
$ wasmtime run -W max-wasm-stack=65536 ...     # stack size cap

10.3.3. WASI sandboxing defaults

By default, a wasmtime WASI module has:

  • No filesystem access. Must grant with --dir.
  • No network access. Must grant with -S inherit-network.
  • No environment variables. Must grant with -S inherit-env.

This is secure-by-default: the host opts in to every capability.

10.3.4. MicroPython WASM via wasmtime

Simon Willison's micropython-wasm (pip install micropython-wasm) bundles a 362KB WASI binary at micropython_wasm/artifacts/micropython-wasi.wasm. On FreeBSD:

  • The Python wrapper fails: the wasmtime Python bindings do not ship FreeBSD wheels. The _ffi.py module checks sys.platform and raises RuntimeError("unsupported platform 'freebsd15'").
  • The WASM binary requires exception handling proposal support, which wasmtime 34 does not implement. pkg install wasmtime (v43) should work but was not tested.
  • The binary also expects custom host imports (micropython_wasm.host_result_cap), so it cannot run standalone via wasmtime run or wasmer run.

Workaround path: install wasmtime v43 from pkg, patch the Python _ffi.py to add a freebsd case pointing to the system libwasmtime.so, and link the FreeBSD .so into the Python package's expected directory structure. Not tested; recorded as future work.

Willison reports GPT-5.5 (xhigh reasoning) failed to escape the sandbox. Default fuel budget: 20 million units.

10.4. Pyodide: sandboxed Python via Node.js

Pyodide runs CPython 3.14.2 compiled to WebAssembly via Emscripten. On FreeBSD 15.0:

$ NODE_PATH=/tmp/pyodide-test/node_modules node test.js
Pyodide loaded on FreeBSD
Basic eval: 4
Python 3.14.2 (main, Jun 9 2026, 08:15:54)
Platform: emscripten

10.4.1. Available packages (all stdlib)

json, re, math, collections, itertools, functools, textwrap, ast, tokenize, io, os, pathlib, csv, dataclasses, typing, unittest, difflib, hashlib – all available.

10.4.2. Sandbox boundaries

Capability Status Notes
Real /etc/passwd BLOCKED Emscripten VFS, no host FS
subprocess BLOCKED No native exec
ctypes BLOCKED No native libraries
socket.connect ALLOWED Creates socket but connect may fail
Emscripten VFS Available Virtual FS at /, /tmp, /home, /dev

The socket result is notable: socket.socket() succeeds (Emscripten provides the API surface) but connect() to real hosts was not observed to succeed. Needs further testing. For defense in depth, wrap the Node.js host process with Deno's network restrictions or run it inside a jail.

10.4.3. Resource usage

226MB RSS. Significantly heavier than Deno (77MB) or wasmtime (22MB). Startup: ~2 seconds for the WASM compilation + Python bootstrap.

10.4.4. Install

npm install pyodide
NODE_PATH=./node_modules node -e "
  const { loadPyodide } = require('pyodide');
  loadPyodide().then(py => console.log(py.runPython('2+2')));
"

10.5. QuickJS: 10KB-class JS engine

Available from FreeBSD ports as quickjs (Bellard, v2025.09.13) or quickjs-ng (community fork, v0.13.0). Not installed on this system.

$ pkg search quickjs
quickjs-2025.09.13.20251222    Embeddable Javascript interpreter in C
quickjs-ng-0.13.0              Embeddable Javascript interpreter in C (NG fork)

Install: pkg install quickjs or pkg install quickjs-ng.

Building from Bellard's tarball on FreeBSD requires patches for malloc_usable_size (needs #include <malloc_np.h>), signal handler types, and environ declaration. The FreeBSD ports tree maintains these patches. Building from source is not recommended; use the port.

QuickJS-NG (flat size 1.28MB) is smaller than Bellard's QuickJS (8.32MB) and more actively maintained.

For the agent sandbox use case, QuickJS fills the "minimal evaluator" niche: run untrusted JS expressions with near-zero overhead. However, Deno already fills this role with a richer permission model, so QuickJS is primarily interesting for embedding (library, not CLI) or for environments where Deno's 68MB binary is too large.

10.6. Comparison matrix

  Deno wasmtime Pyodide MicroPython WASM QuickJS
FreeBSD pkg Yes Yes npm pip (broken FFI) Yes
Binary size 68MB 28MB 9.6MB (wasm) 362KB (wasm) 1.3MB
RSS (hello world) 77MB 22MB 226MB N/A <5MB
Startup 60ms 10ms ~2s N/A <10ms
Language TS/JS Any→WASM Python Python (subset) JS
FS isolation Flags WASI Emscripten VFS WASI None
Net isolation Flags WASI Host-dependent WASI None
CPU limits None Fuel None Fuel None
Memory limits V8 WASM Node.js WASM Heap cap
Permission model Rich Capability Implicit Capability None
Escape testing N/A N/A N/A GPT-5.5 xhigh N/A
Best for General eval Polyglot WASM Python audit Minimal Python Embedding

10.7. Recommendation for this site

For the daily audit use case (style checking, header validation):

  1. Primary: Deno with --allow-read=site/ --allow-net=192.168.86.22:4000. Already installed, tested, rich permission model. 60ms startup.
  2. Python-specific audit: Pyodide via Node.js. Heavier (226MB, 2s startup) but runs real Python with full stdlib. Useful for the org_title_lint.py style checker and similar Python tools.
  3. Future: MicroPython WASM via wasmtime CLI once wasmtime 43 is installed from pkg and the Python FFI is patched for FreeBSD. The 362KB binary with fuel limits is the lightest sandboxed Python option.

For the jail-based agent workflow described in the sections above, these WASM tools compose as an additional layer: the jail provides OS-level isolation (process, filesystem, network via pf), and the WASM sandbox provides per-tool-call isolation within the jail.

11. Two-boundary framing (from cross-host validation)

Most "agent sandbox" writing conflates two distinct boundaries:

11.1. 1. Credential boundary (who holds the keys)

The LiteLLM proxy IS the credential boundary. It holds provider keys (Anthropic, OpenAI, Google); agents get a low-privilege proxy key (sk-litellm-local) and an egress allowlist to only the proxy.

Evidence (validated from hydra → mini, cross-OS):

# Proxy key → local Ollama → content at $0, provider keys never in scope
$ curl -s http://192.168.86.22:4000/v1/chat/completions \
    -H "Authorization: Bearer $(pass show litellm/master-key)" \
    -H "Content-Type: application/json" \
    -d '{"model":"ollama-llama3.2","messages":[{"role":"user","content":"hello"}]}'
# => 200, content returned

# Wrong key → 401, no fallback
$ curl -s http://192.168.86.22:4000/v1/chat/completions \
    -H "Authorization: Bearer sk-wrong"
# => 401 {"error":{"type":"token_not_found_in_db"}}

11.2. 2. Execution boundary (what confines the agent)

The jail/container/sandbox confines the agent's process. It cannot read credential stores, cannot exfiltrate via network (only the proxy is reachable), cannot escape to the host.

Decoupling is the key move: you can harden each independently. The proxy can rotate provider keys without touching jails. Jails can be rebuilt without touching the proxy config.

11.3. Execution-boundary matrix by uname

OS Native Container Lightweight
FreeBSD Bastille jails (kernel, VNET) Podman (slow, Linux emu) Capsicum, Deno
macOS sandbox-exec / Seatbelt Docker Desktop (GUI-bound), colima Deno, sandbox-runtime
Linux namespaces + seccomp podman, Docker, gVisor, Firecracker bubblewrap, Deno, Pyodide

11.4. Headless changes the pick

On a headless node, Docker Desktop is the wrong tool (needs a GUI session). colima (macOS) or jails (FreeBSD) win. This is a real finding from the hydra/nexus setup — both are headless servers.

11.5. The "dumb harness" principle

The agent should not know its own credentials. The test harness can be trivial (plain curl is sufficient). That simplicity is a security property, not a shortcut. If the harness needs complex credential management, the boundary is in the wrong place.

11.6. Cross-host validation as method

Vet the boundary from a different OS: FreeBSD (hydra) validating the macOS gateway (mini). The literate org gist runs block-by-block: Layer 0 reachability → model list → keyless Gemini → keyless Ollama → wrong-key-401 → metrics.

Source: litellm-freebsd-validation.org

12. Validated: guile-sage + efrit from Bastille jail (2026-06-19)

Both Guile Scheme (guile-sage) and Emacs (efrit) successfully called LiteLLM from inside a Bastille jail on hydra (FreeBSD 14.3):

guile-sage in claude-sandbox jail (10.20.30.5)
LiteLLM: http://192.168.86.22:4000
Model: gemini-2.5-flash

Response:
Jails hold processes,
Each in its own small sandbox,
Host stays ever safe.

Architecture validated:

  • Jail IP: 10.20.30.5 (VNET, pf NAT)
  • Proxy: LiteLLM at 192.168.86.22:4000
  • Model: gemini-2.5-flash (cloud, via proxy)
  • Credential isolation: jail holds sk-litellm-local only
  • Ollama models timeout from jail (expected: Ollama slower than Gemini)