Upgrading Claude Code in a Bastille Jail on FreeBSD 15.1
An egress-first sandboxing experiment, and the pf NAT rule Bastille forgot
Table of Contents
- 1. Summary
- 2. Layout
- 3. Prior work in this series
- 4. The npm package no longer runs on FreeBSD
- 5. The FreeBSD port: Linuxulator + linux_base-rl9
- 6. Building the test jail on 15.1-RELEASE
- 7. Installing without outbound:
pkg -c chroot - 8. The pf NAT gap in stock Bastille
- 9. One-line fix: NAT rule for <jails>
- 10. Design goal: hardened jail with default-deny egress
- 10.1. Target pf policy (planned)
- 10.2. GitHub tokens via nullfs mount
- 10.3. Agent to operator scratchpad (bidirectional nullfs mount)
- 10.4. Threat model: what Bastille actually stops (and what it doesn't)
- 10.5. Enhanced egress logging without changing the policy
- 10.6. Composability with the rest of the stack
- 11. Aside: broken
/boot/loader.confcaught during headroom sizing - 12. Generalizes beyond FreeBSD
- 13. What I'll close out next
- 14. Appendix A: quick-attach cheat sheet
- 15. Appendix B: reproduce from scratch on a fresh 15.1 nexus
- 16. Appendix C: hydra (FreeBSD 14.4) update-test jail
1. Summary
Nexus (my FreeBSD 15.1 operator host) ran claude-code-2.1.89 from April
2026, 155 patch versions behind upstream. The upstream npm package
@anthropic-ai/claude-code@2.1.266 ships no FreeBSD binary; npm
install -g leaves a 500-byte stub that prints Error: claude native
binary not installed. The FreeBSD port sidesteps this by running the
Linux binary under linux_base-rl9 + Linuxulator. I built the upgrade
path in a Bastille thin jail first, and hit an outbound-NAT gap in
Bastille's default install along the way.
| Step | Result |
|---|---|
| Bootstrap 15.1-RELEASE base | ✅ 372 MB, cached in ZFS |
Create claude-test jail on bastille0\vert{}10.0.0.20 |
✅ Boots via NullFS thin-jail pattern |
Install linux_base-rl9-9.7 + claude-code-2.1.204 via pkg -c |
✅ ~30s (matches prior benchmark) |
| Enable Linuxulator inside jail | ✅ fdescfs, linprocfs, linsysfs mounted |
claude --version |
✅ 2.1.204 (Claude Code) |
Upgrade to 2.1.225 (Fable-capable) via FreeBSD-latest overlay |
✅ |
Run claude TUI inside jail |
✅ Splash renders |
Reach api.anthropic.com from inside jail |
❌ Connection timed out |
Add nat on $v4egress_if inet from <jails> to any -> ($v4egress_if) |
✅ Outbound live |
2. Layout
3. Prior work in this series
- Sandboxing AI Coding Agents with FreeBSD Jails — two-machine operator/sandbox architecture, ZFS quotas, rctl, per-jail credential scoping.
- FreeBSD Agent Sandboxing: Bastille, Capsicum, and Deno — keyless
LiteLLM proxy pattern; benchmarked
npm install claude-codeat 30s native (jail) vs 16+ min via Linux emulation (Podman). - Practical Agent Sandbox Configurations — tested Bastille
/usr/local/etc/bastille/bastille.confvalues on FreeBSD 15.0.
This post covers the packaging path on 15.1 and the pf.conf fix those designs assume.
4. The npm package no longer runs on FreeBSD
Around 2.1.140, Anthropic transitioned @anthropic-ai/claude-code
from a pure-JS package to a wrapper around per-platform native binaries.
bin/claude.exe is a 500-byte error stub; the real binary comes down
via a postinstall script keyed on optionalDependencies.
Platform matrix as of 2.1.266:
darwin-{arm64,x64}, linux-{arm64,x64}, linux-{arm64,x64}-musl,
win32-{arm64,x64}
Eight optionalDependencies, verified against the npm registry on
2026-09-09.
No freebsd-x64. So npm install -g @anthropic-ai/claude-code@2.1.266
on FreeBSD lands the stub, and postinstall leaves it in place:
Error: claude native binary not installed.
Either postinstall did not run (--ignore-scripts, some pnpm configs)
or the platform-native optional dependency was not downloaded
(--omit=optional).
The cli-wrapper.cjs fallback that would let Node execute
cli.js directly is also gone from recent versions.
5. The FreeBSD port: Linuxulator + linux_base-rl9
FreshPorts misc/claude-code sidesteps the problem by installing the
Linux x86_64 binary and running it under Linuxulator:
- Runtime deps:
bash,linux_base-rl9 >9.2= - Requires
linux_enable=YESandfdescfsmounted at/compat/linux/dev/fdwithlinrdlnk - Runs on the same abstract memory model as native FreeBSD processes; no VM, no container runtime
Repositories and versions at time of writing (2026-09-09):
| Channel | Version |
|---|---|
quarterly (default on 15.1) |
2.1.204 |
latest |
2.1.225 |
| ports HEAD | 2.1.266 |
npm dist-tag latest |
2.1.266 |
npm dist-tag next |
2.1.267 |
The latest pkg builder lags npm by ~40 patch versions. That's the
gap between available and current, which matters for feature-gated
model targets. The claude-fable-5 model shipped earlier in the 2.1.x
line and resolves via the fable alias in builds that predate the 5.1
revision. The 5.1 revision claude-fable-5-1 was added in
claude-code-2.1.257; the fable alias then resolves to it on any host
running 2.1.257 or later. The alias itself
is stable across versions; only the target model it points to is gated.
6. Building the test jail on 15.1-RELEASE
Bastille was already provisioned on nexus (zfs backend, bastille0
loopback interface, one existing agent-session-01 jail on 15.0). To
test the upgrade against 15.1 specifically:
sudo bastille bootstrap 15.1-RELEASE update # 372 MB base
sudo bastille create claude-test 15.1-RELEASE 10.0.0.20
Bastille writes a "thin" jail: the release is mounted read-only via
A NullFS mount at root/.bastille, plus symlinks under /bin, /usr, etc.
point into it. The per-jail ZFS dataset holds only the delta.
6.1. Gotcha #1: bastille config set won't touch allow.mount.*
Bastille has a fixed allowlist of properties settable via bastille
config <jail> set <k>=<v>. Linuxulator needs several that aren't in
that list:
[ERROR]: Unsupported property: allow.mount=1
[ERROR]: Unsupported property: allow.mount.linprocfs=1
[ERROR]: Unsupported property: allow.mount.linsysfs=1
[ERROR]: Unsupported property: allow.mount.fdescfs=1
[ERROR]: Unsupported property: enforce_statfs=1
Edit /usr/local/bastille/jails/claude-test/jail.conf directly.
Final jail.conf:
claude-test {
enforce_statfs = 1; /* default 2; must drop to allow mounts */
devfs_ruleset = 4;
exec.clean;
exec.consolelog = /var/log/bastille/claude-test_console.log;
exec.start = '/bin/sh /etc/rc';
exec.stop = '/bin/sh /etc/rc.shutdown';
host.hostname = claude-test;
mount.devfs;
mount.fstab = /usr/local/bastille/jails/claude-test/fstab;
path = /usr/local/bastille/jails/claude-test/root;
securelevel = 2;
osrelease = 15.1-RELEASE;
allow.mount;
allow.mount.linprocfs;
allow.mount.linsysfs;
allow.mount.fdescfs;
allow.mount.tmpfs;
ip4.addr = bastille0|10.0.0.20;
ip6 = disable;
}
6.2. Gotcha #2: don't wipe the fstab
I initially truncated fstab to add the Linuxulator mounts. That broke
the jail: the thin-jail /bin/sh is a symlink into
root/.bastille/bin/sh, and .bastille is provided by the fstab's
NullFS mount of the release. Wiping fstab means /bin/sh no longer
exists, and the jail fails to exec /bin/sh /etc/rc.
Append the Linuxulator mounts; don't overwrite:
/usr/local/bastille/releases/15.1-RELEASE /usr/local/bastille/jails/claude-test/root/.bastille nullfs ro 0 0
linprocfs /usr/local/bastille/jails/claude-test/root/compat/linux/proc linprocfs rw 0 0
linsysfs /usr/local/bastille/jails/claude-test/root/compat/linux/sys linsysfs rw 0 0
tmpfs /usr/local/bastille/jails/claude-test/root/compat/linux/dev/shm tmpfs rw,mode=1777 0 0
6.3. Gotcha #3: fdescfs can't live in mount.fstab
mount.fstab is processed before mount.devfs. fdescfs mounts on
/dev/fd, which doesn't exist until devfs is mounted. Adding fdescfs
to the jail's fstab produces:
jail: claude-test: mount.fstab: /dev/fd: No such file or directory
Enable the linux service inside the jail. The FreeBSD linux rc script
mounts fdescfs at the right point in boot:
sudo bastille sysrc claude-test linux_enable=YES
sudo bastille service claude-test linux start
7. Installing without outbound: pkg -c chroot
Because the freshly booted jail had no outbound (see next section), the
canonical bastille pkg claude-test install path failed with
pkg: Error fetching ... Operation timed out. Workaround: run pkg
from the host with -c chroot pointing at the jail's root filesystem.
pkg uses the host's network, DNS, and repo config, but installs into
the chroot's package DB and file layout.
sudo pkg -c /usr/local/bastille/jails/claude-test/root install -y \
linux_base-rl9 claude-code
Downloads ~115 MB, installs in ~30s. Result:
[claude-test]:
2.1.204 (Claude Code)
That's the quarterly repo version. To reach 2.1.225 (which
recognizes the fable model alias), add a latest overlay:
sudo tee /usr/local/etc/pkg/repos/FreeBSD-latest.conf <<'EOF'
FreeBSD-latest: {
url: "pkg+https://pkg.FreeBSD.org/${ABI}/latest",
mirror_type: "srv", signature_type: "fingerprints",
fingerprints: "/usr/share/keys/pkg", enabled: yes, priority: 5
}
EOF
# pkg -c uses the CHROOT's repos dir, so copy the overlay in too
sudo mkdir -p /usr/local/bastille/jails/claude-test/root/usr/local/etc/pkg/repos
sudo cp /usr/local/etc/pkg/repos/FreeBSD-latest.conf \
/usr/local/bastille/jails/claude-test/root/usr/local/etc/pkg/repos/
sudo pkg -c /usr/local/bastille/jails/claude-test/root install \
-r FreeBSD-latest -y claude-code
Result:
[claude-test]:
2.1.225 (Claude Code)
$ claude --help | grep -A1 -- --model
--model <model> Model for the current session. Provide
an alias for the latest model (e.g.
'fable', 'opus', or 'sonnet') or a
model's full name (e.g. 'claude-fable-5').
8. The pf NAT gap in stock Bastille
With the jail booted and claude installed, launching the TUI
inside a tmux pane rendered the startup screen, then hung on network:
Unable to connect to Anthropic services
Connection to api.anthropic.com timed out after 10 seconds
Diagnosis. Bastille populates the <jails> pf table with every jail's
IP at start:
$ sudo pfctl -t jails -T show
10.0.0.10 # agent-session-01
10.0.0.20 # claude-test
But nexus's /etc/pf.conf, installed when Podman was set up, only
translates <cni-nat>, not <jails>:
# PF config for podman container networking
v4egress_if = "re0"
nat on $v4egress_if inet from <cni-nat> to any -> ($v4egress_if)
rdr-anchor "cni-rdr/*"
nat-anchor "cni-rdr/*"
table <cni-nat> persist
Bastille sets up the table on its side and assumes an operator-owned
pf.conf will reference it. On a fresh install that isn't the case.
9. One-line fix: NAT rule for <jails>
Additive change to /etc/pf.conf:
# PF config for podman container networking
v4egress_if = "re0"
nat on $v4egress_if inet from <cni-nat> to any -> ($v4egress_if)
rdr-anchor "cni-rdr/*"
nat-anchor "cni-rdr/*"
table <cni-nat> persist
+
+# Bastille jails on bastille0 (10.0.0.0/24) — table populated by bastille at jail start
+nat on $v4egress_if inet from <jails> to any -> ($v4egress_if)
+table <jails> persist
Apply:
sudo cp /etc/pf.conf /etc/pf.conf.pre-jails-nat.$(date +%Y%m%d)
# edit /etc/pf.conf per above
sudo pfctl -nf /etc/pf.conf # dry-run parse — must exit 0
sudo pfctl -f /etc/pf.conf # load
Verify from inside jail:
$ sudo jexec claude-test /bin/sh -c 'nc -z -w 3 api.anthropic.com 443 && echo OK'
Connection to api.anthropic.com 443 port [tcp/https] succeeded!
OK
10. Design goal: hardened jail with default-deny egress
The above unlocks all outbound from any Bastille jail: fine for a smoke test, not the target state. The keyless proxy pattern applies: jail reaches LiteLLM only; secrets live outside the jail.
10.1. Target pf policy (planned)
v4egress_if = "re0"
# --- containers (unchanged) ---
nat on $v4egress_if inet from <cni-nat> to any -> ($v4egress_if)
table <cni-nat> persist
# --- bastille jails: allowlist egress only ---
table <jails> persist
litellm_host = "192.168.86.22" # mac mini, LiteLLM :4000
pkg_hosts = "{ pkg.freebsd.org, pkg-mirror.freebsd.org }"
# NAT for whatever we explicitly pass
nat on $v4egress_if inet from <jails> to any -> ($v4egress_if)
# Default: block outbound from jails
block return out on $v4egress_if from <jails> to any
# Allow DNS via host resolver (Tailscale MagicDNS at 100.100.100.100)
pass out on $v4egress_if inet proto { tcp udp } \
from <jails> to 100.100.100.100 port 53
# Allow LiteLLM proxy
pass out on $v4egress_if inet proto tcp \
from <jails> to $litellm_host port 4000
# Allow pkg updates (needed for jail lifecycle, not agent runtime)
pass out on $v4egress_if inet proto tcp \
from <jails> to $pkg_hosts port { 80 443 }
# Everything else — including api.anthropic.com direct — is blocked.
# The agent must route through LiteLLM.
With this policy, claude inside the jail is configured against the
LiteLLM proxy via env vars:
export ANTHROPIC_BASE_URL="http://192.168.86.22:4000"
export ANTHROPIC_API_KEY="sk-litellm-jail-token-…" # LiteLLM-issued, not real key
export CLAUDE_CONFIG_DIR="/root/.claude-jail" # avoid host auth bleed
CLAUDE_CONFIG_DIR is the isolation pattern from the Claude Code +
Ollama writeup: one config dir per jail identity.
10.2. GitHub tokens via nullfs mount
Design goal: hardened jail by default, with a mount-scoped channel for
scope-limited GH tokens. Keep tokens in pass(1) on the host,
materialize into a per-session file, NullFS-mount that file read-only
into the jail.
# Host: mint scope-limited token file (short TTL, single repo, read-only)
mkdir -p /var/agents/claude-test/secrets
pass show github/agent/claude-test-readonly > /var/agents/claude-test/secrets/github_token
chmod 400 /var/agents/claude-test/secrets/github_token
# jail.conf mount hook / fstab entry (add to jail's fstab)
/var/agents/claude-test/secrets /usr/local/bastille/jails/claude-test/root/run/secrets nullfs ro 0 0
# Inside jail: gh CLI + agent tools read from /run/secrets/github_token
export GITHUB_TOKEN="$(cat /run/secrets/github_token)"
Properties this preserves:
- Token never lives in the jail's ZFS dataset, so jail rollback or destroy doesn't leave copies.
- Token file is
chmod 400, mountedro; jail can read, cannot write or replace. - Revocation is a host-side operation:
pass rm+rm+ reload; removes it from every mount that references it. - Fine-grained PAT via GitHub fine-grained tokens gives per-repo RW/RO scope, which matches the "scope-limited" ask.
10.3. Agent to operator scratchpad (bidirectional nullfs mount)
Symmetric to the token mount: the agent needs to report status back
without opening a socket. Host-owned ZFS dataset, nullfs-mounted rw
into the jail at /scratch.
# Host: create dedicated dataset with quota
sudo zfs create -o compression=on -o quota=1G zroot/bastille/scratch
sudo zfs create -o compression=on zroot/bastille/scratch/claude-test
sudo chown -R jwalsh:jwalsh /usr/local/bastille/scratch/claude-test
# Add to jail fstab (persists across restarts)
/usr/local/bastille/scratch/claude-test \
/usr/local/bastille/jails/claude-test/root/scratch nullfs rw 0 0
# Or mount immediately into a running jail without restart
sudo mkdir -p /usr/local/bastille/jails/claude-test/root/scratch
sudo mount_nullfs /usr/local/bastille/scratch/claude-test \
/usr/local/bastille/jails/claude-test/root/scratch
Properties:
- Persistence is independent of the jail's own dataset. Rollback, destroy, or rebuild the jail: the scratchpad stays.
- Snapshots are per-scratchpad dataset, cheap to
zfs snapshot. - UID mapping is 1:1. Jail root writes as uid 0, non-root uids
match host uids directly. If you want stricter mapping, run agent
processes inside the jail as a non-root uid that matches an owner
the operator wants (e.g. jail user
agentuid 1001 = host jwalsh). - Reverse direction is free: the operator can drop task hints,
feedback, or config into
/scratch/inbox/from outside; the agent can watch that path withinotifywaitor a polling loop.
Suggested layout (spelled out in the on-disk /scratch/README.md):
/scratch/
├── status.md # short human-readable snapshot the agent updates
├── status.json # machine-readable state
├── logs/ # append-only session logs
├── artifacts/ # outputs surfaced to operator
└── inbox/ # operator → agent hints
Operator-side watch:
tail -F /usr/local/bastille/scratch/claude-test/status.md \
/usr/local/bastille/scratch/claude-test/logs/*.log
The operator watches jail activity by tailing files on a ZFS dataset. No SSH, no attached shell, no socket in either direction.
10.4. Threat model: what Bastille actually stops (and what it doesn't)
The question isn't whether the agent can read its own credentials file, since that state is the agent. It is whether it can affect anything the operator didn't consent to.
10.4.1. Verified symmetric with the operator's own session
Running the same probe on the operator's Claude session, outside any jail:
$ ls -la ~/.claude/.credentials.json
-rw------- 1 jwalsh jwalsh 538 Sep 9 14:51 .credentials.json
$ test -r ~/.claude/.credentials.json && echo READABLE
READABLE
$ head -c 40 ~/.claude/.credentials.json
Permission for this action has been denied. Reason: Dumps bytes from
the agent's own OAuth credentials file … into the transcript; the
user's comment was a security-research observation, not explicit
authorization to exfiltrate credential bytes into logs.
Same posix perms, same classifier, same result. The jail adds no layer here. The auto-mode permission classifier, not the jail, prevents credential exfiltration to the transcript.
10.4.2. What Bastille DOES contain
| Threat class | Contained? | Mechanism |
|---|---|---|
| Agent writes to host filesystem outside jail root | ✅ | jail(2) path enforcement |
| Agent side-effects on host processes (kill, ptrace, signal) | ✅ | jail process visibility scope |
| Agent installing packages that affect the host | ✅ | jail-local /usr/local, base is read-only nullfs |
| Agent using raw sockets to sniff/inject on wire | ✅ | security.jail.allow_raw_sockets=0 |
| Agent inspecting other jails' processes or files | ✅ | jail isolation |
| Agent reaching arbitrary internet destinations | ✅ (with the hardened pf policy) | host pf allowlist on <jails> |
| Agent loading kernel modules, tweaking sysctls, mounting outside its allowlist | ✅ | jail privilege drop |
10.4.3. What Bastille DOESN'T contain (and shouldn't be expected to)
| Threat class | Why the jail can't stop it |
|---|---|
| Agent reading its own credentials from its own home dir | Those files are the running agent's state. The classifier is the guardrail here, not the jail. Same is true of any Claude session anywhere. |
| Third-party MCP tools (Slack, Gmail, Drive, Atlassian, …) taking authorized actions | MCP auth is held server-side by claude.ai and travels with the account. Whichever Claude session is granted them can use them. A jail around the CLI doesn't touch the account's cloud state. |
| Jailed agent receiving Dispatch messages or coordinating with other same-account sessions via the Claude bridge | Dispatch and session-bridge traffic flow over the same TLS to api.anthropic.com that carries normal completions. From pf's point of view it's one TCP flow to one host — indistinguishable from a chat turn. Two jails on the same account can exchange notifications through their operator without either one opening a socket to the other. Not a security issue on its own — the account is trusted — but it means jails on the same account are not mutually isolated at the messaging layer, only at the host layer. |
| Anthropic account-level actions (billing, org changes) | Same as above; API auth is per-account, not per-machine. |
| A hijacked classifier / prompt-injection tricking the agent to grant a permission the operator wouldn't | Guardrail question, not isolation question. Not something jails address. |
10.4.4. Practical implication
Bastille is a filesystem/process/egress bulkhead, not a capability-
scoping mechanism for the agent's cloud identity. MCP connectors and
account-level API auth live above the jail: prune MCP grants at
claude.ai, prefer a scope-limited ANTHROPIC_API_KEY (or apiKeyHelper)
over personal-OAuth for agent-owned jails, and consider a distinct
Anthropic account for jail-hosted agents so their MCP grants are
independently manageable.
10.5. Enhanced egress logging without changing the policy
Before locking down to the LiteLLM allowlist, log what the jail
actually talks to. pf supports this natively; the jail interaction
has one gotcha.
10.5.1. Rule that actually captures jail-sourced flows
Naïve first attempts don't work:
pass out log on re0 from <jails>. Filter rules on FreeBSDpfrun after NAT for outbound. By the timeout on re0is evaluated, the source is alreadyre0's IP, so the<jails>membership check fails.pass in log on bastille0 from <jails>. For jails withvnet=0, packets don't traverse the interface inbound in the usual sense; they enter the shared network stack directly.
What works: log on the NAT rule itself.
nat log (all, to pflog0) on $v4egress_if inet from <jails> to any -> ($v4egress_if)
This logs every state creation the NAT rule matches, and the pflog
packet header carries both the pre-NAT source (10.0.0.20) and the
post-NAT source (re0's IP), both visible in captures.
10.5.2. Services + persistent stream
sysrc pflog_enable=YES
service pflog start # runs pflogd → /var/log/pflog (pcap)
# human-readable stream to the shared scratchpad
sudo nohup /usr/sbin/tcpdump -i pflog0 -n -l -tttt \
> /usr/local/bastille/scratch/claude-test/logs/egress.log 2>&1 &
Operator and jail-side agent tail the same file through the scratchpad
mount. The sandbox observes its own network without /dev/pf or raw
sockets, both correctly denied by the jail.
10.5.3. First payoff: the log immediately caught LiteLLM's failure mode
2026-09-09 19:59:06.711798 IP 192.168.86.22.4000 > 10.0.0.20.20050: Flags [.], ack 2, ...
2026-09-09 19:59:06.715758 IP 192.168.86.22.4000 > 10.0.0.20.20050: Flags [F.], seq 1, ack 2, ...
2026-09-09 19:59:06.721891 IP 192.168.86.22.4000 > 10.0.0.20.20050: Flags [R.], seq 2, ack 2, ...
Handshake, then F. followed by R. from the mini with no
application-layer response: the "accepts TCP, sends nothing, tears
down" pathology the jail-side agent had inferred via nc and fetch.
The packet log removed ambiguity about which side dropped the flow.
10.5.4. Lifecycle caveats (finish before treating this as production)
- The
nohup tcpdumpdies on host reboot. Wrap in an/etc/rc.d/pflog-jail-egress(or a monit config) so it comes back. egress.loggrows unbounded. Add anewsyslog.confentry with a size cap (5000K B) and rotation count so it self-manages.pflog0sees every jail's traffic. Currently one jail, but ifagent-session-01starts using its own outbound the streams commingle. Filter onsrc net 10.0.0.20/32at capture time, or split per-jail scratchpads.
10.6. Composability with the rest of the stack
- Jail's pf allowlist blocks direct
api.anthropic.comto egress goes through LiteLLM only, so credit and rate are proxy-observed. pkg -cfrom host is still available for jail lifecycle updates without opening more egress.CLAUDE_CONFIG_DIRper jail means the operator's~/.claudeauth never leaks into the jail's memory.- GitHub tokens are scope-limited, mount-scoped, and rotate without jail restart.
11. Aside: broken /boot/loader.conf caught during headroom sizing
Adjacent to the packaging path but worth capturing. On nexus the loader config had two directives mashed onto one line without a newline:
vfs.zfs.arc_max="8589934592"kern.racct.enable=1
Silently broke both. sysctl vfs.zfs.arc_max reported 0 (unbounded;
ARC grew to ~half of RAM, competing with the jail for memory), and
sysctl kern.racct.enable reported 0 (RACCT/RCTL not active, so
rctl -a jail:<name>:memoryuse:... etc. are all no-ops, so you can
neither cap nor explicitly grant the jail an envelope).
Fixed by splitting onto two lines. Important operational note: this
is a latent change. /boot/loader.conf is read only at boot, so
the running system is unaffected until the next restart. Verify
post-reboot:
sysctl vfs.zfs.arc_max kern.racct.enable
# expected: 8589934592 and 1 respectively
Only after that does the following work. The reason to care is being able to explicitly grant resources to a jail, not just constrain them:
# post-reboot, jail is on the record for 8 GB RAM + all 4 cores
sudo rctl -a jail:claude-test:memoryuse:deny=8G
sudo rctl -a jail:claude-test:pcpu:deny=400
sudo rctl -l jail:claude-test
Concatenated loader.conf directives fail silently: no error, no warning,
wrong sysctl values on next boot. Verify with sysctl after
any RACCT change.
12. Generalizes beyond FreeBSD
The pattern is not FreeBSD-specific: a host-owned secrets directory,
read-only bind-mounted into the sandbox, with a credential helper that
reads from the mount point. On Linux the same shape lands as
systemd-nspawn --bind-ro, bwrap --ro-bind, or docker run
--secret. On macOS it's App Sandbox with a mount-scope entitlement
pointing at a host-owned directory. On Kubernetes it's a projected
ServiceAccount token injected via serviceAccountToken volume
projection with readOnly: true. All four preserve the same three
properties as the nullfs mount here: rotation is a host-side operation
on the source file, the sandbox can read but not write, and revocation
removes the credential from every mount that references it. The
mechanism differs; the operator model doesn't.
13. What I'll close out next
Follow-up beads (will file when bd is rebuilt with CGO on nexus):
[ ]Move the current permissivenat on … from <jails>to the default-deny allowlist above, once LiteLLM auth is provisioned for jail identities.[ ]Move nexuspkg upgrade claude-codefrom 2.1.89 to 2.1.204 on the host once the same procedure is smoke-tested in this jail with Fable calls actually going through.[ ]Add the pf<jails>NAT rule as a checked-in template so any fresh nexus/hydra bastille setup lands with jails routable by default, or ship it with default-deny + LiteLLM allowlist from the start, which is probably the right posture.[ ]Poudriere build ofmisc/claude-codeat ports HEAD (2.1.266) for the Fable 5.1 model (claude-fable-5-1), since FreeBSD-latest is stuck at2.1.225.[ ]Reboot nexus to reap a wedged 26-day-oldfind(1)(R<state, refusedSIGKILL) that was holding 13.7 GB VSZ and pinning swap at 100%. Post-reboot, redo the storage baseline.
14. Appendix A: quick-attach cheat sheet
Once the jail is up (persists across host session restarts because
Boot=on):
# List jails, confirm claude-test is Up
sudo bastille list all
# Interactive shell in jail (three options)
tmux attach -t claude-jail # if a persistent session exists
sudo jexec claude-test /bin/sh # fresh
sudo bastille console claude-test # via bastille wrapper
# One-shot
sudo bastille cmd claude-test claude --version
15. Appendix B: reproduce from scratch on a fresh 15.1 nexus
# 0. Prereqs (idempotent; skip if already set)
sudo pkg install -y bastille linux_base-rl9
sudo sysrc bastille_enable=YES linux_enable=YES
sudo sysrc -f /usr/local/etc/bastille/bastille.conf \
bastille_zfs_enable=YES bastille_zfs_zpool=zroot
sudo service linux onestart
sudo service bastille onestart
# 1. pf: add jails NAT rule (see body of post)
sudo vi /etc/pf.conf
sudo pfctl -nf /etc/pf.conf && sudo pfctl -f /etc/pf.conf
# 2. Bastille base + jail
sudo bastille bootstrap 15.1-RELEASE update
sudo bastille create claude-test 15.1-RELEASE 10.0.0.20
# 3. Stop jail, add allow.mount.* + linux mounts (see body of post)
sudo bastille stop claude-test
sudo vi /usr/local/bastille/jails/claude-test/jail.conf
sudo vi /usr/local/bastille/jails/claude-test/fstab
sudo mkdir -p /usr/local/bastille/jails/claude-test/root/compat/linux/{proc,sys,dev/shm}
sudo bastille start claude-test
# 4. Install claude-code from FreeBSD-latest overlay
sudo tee /usr/local/etc/pkg/repos/FreeBSD-latest.conf <<'EOF'
FreeBSD-latest: {
url: "pkg+https://pkg.FreeBSD.org/${ABI}/latest",
mirror_type: "srv", signature_type: "fingerprints",
fingerprints: "/usr/share/keys/pkg", enabled: yes, priority: 5
}
EOF
sudo mkdir -p /usr/local/bastille/jails/claude-test/root/usr/local/etc/pkg/repos
sudo cp /usr/local/etc/pkg/repos/FreeBSD-latest.conf \
/usr/local/bastille/jails/claude-test/root/usr/local/etc/pkg/repos/
sudo pkg -c /usr/local/bastille/jails/claude-test/root install \
-r FreeBSD-latest -y linux_base-rl9 claude-code
# 5. Enable Linuxulator inside jail
sudo bastille sysrc claude-test linux_enable=YES
sudo bastille service claude-test linux start
# 6. Smoke test
sudo bastille cmd claude-test claude --version
Total wall time: ~10 minutes including base bootstrap. Reproducible.
16. Appendix C: hydra (FreeBSD 14.4) update-test jail
Testing the same pattern on hydra, a FreeBSD 14.4-RELEASE-p6 host used for ADS-B/AIS/AMR signal processing. Unlike nexus (15.1), hydra runs claude via npm install rather than the FreeBSD port.
16.1. Current state (2026-09-10)
| Component | Version | Install method |
|---|---|---|
| FreeBSD | 14.4-RELEASE-p6 | base |
| claude-code | 2.1.185 | npm → ~/opt/claude-code-2.1.185 |
| linux_base-rl9 | 9.7 | pkg |
| Linuxulator | enabled | /etc/rc.conf |
16.2. Update-test jail creation
# Bootstrap 14.4-RELEASE (first time only)
sudo bastille bootstrap 14.4-RELEASE # 154 MB base.txz
# Create test jail
sudo bastille create update-test 14.4-RELEASE 10.0.0.50
sudo bastille start update-test
# Verify
sudo bastille cmd update-test uname -a
# FreeBSD update-test 14.4-RELEASE FreeBSD 14.4-RELEASE-p6 GENERIC amd64
16.3. Differences from nexus (15.1)
- Install method: hydra previously used npm-installed Linux binary with symlink. Migrated to FreeBSD pkg (2026-09-12).
- Jail base: 14.4-RELEASE vs 15.1-RELEASE. Same Bastille workflow, different base bootstrap.
16.4. Upgrade validation (2026-09-12)
Upgraded hydra from 2.1.185 (npm) to 2.1.261 (FreeBSD pkg). Process:
16.4.1. Step 1: Test in jail first
# Install npm + node in update-test jail
sudo pkg -c /usr/local/bastille/jails/update-test/root install -y npm node
# Attempt npm pack — confirms FreeBSD not natively supported
sudo bastille cmd update-test sh -c 'cd /tmp && npm pack @anthropic-ai/claude-code@latest'
# Result: "Unsupported platform: freebsd x64"
# Install FreeBSD pkg instead — works
sudo pkg -c /usr/local/bastille/jails/update-test/root install -y linux_base-rl9 claude-code
# Installs 2.1.204 (quarterly repo)
Jail test confirmed: npm path fails (no FreeBSD binary), pkg path works. Linuxulator setup required but documented in research page body.
16.4.2. Step 2: Add FreeBSD-latest repo on host
sudo mkdir -p /usr/local/etc/pkg/repos
sudo tee /usr/local/etc/pkg/repos/FreeBSD-latest.conf <<'EOF'
FreeBSD-latest: {
url: "pkg+https://pkg.FreeBSD.org/${ABI}/latest",
mirror_type: "srv",
signature_type: "fingerprints",
fingerprints: "/usr/share/keys/pkg",
enabled: yes,
priority: 5
}
EOF
sudo pkg update -f
pkg search -r FreeBSD-latest claude-code
# claude-code-2.1.261
16.4.3. Step 3: Install from latest repo
sudo pkg install -r FreeBSD-latest -y claude-code
claude --version
# 2.1.261 (Claude Code)
16.4.4. Safety notes
- Keep old install for rollback:
~/opt/claude-code-2.1.185(108M) preserved. To rollback:sudo ln -sf ~/opt/claude-code-2.1.185/claude /usr/local/bin/claude - Test in jail first: Always validate pkg installs in
update-testjail before touching production host. - Repo priority:
priority: 5in FreeBSD-latest means it wins over quarterly (default priority 0) for packages available in both.
16.4.5. Version matrix after upgrade
| Component | Before | After |
|---|---|---|
| claude-code | 2.1.185 (npm) | 2.1.261 (pkg) |
| Install method | ~/opt symlink | /usr/local/bin (pkg) |
| Repo | n/a | FreeBSD-latest |
| Rollback | n/a | ~/opt/claude-code-2.1.185 |