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

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

jail-egress-layout.png

3. Prior work in this series

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=YES and fdescfs mounted at /compat/linux/dev/fd with linrdlnk
  • 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:

  1. Token never lives in the jail's ZFS dataset, so jail rollback or destroy doesn't leave copies.
  2. Token file is chmod 400, mounted ro; jail can read, cannot write or replace.
  3. Revocation is a host-side operation: pass rm + rm + reload; removes it from every mount that references it.
  4. 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 agent uid 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 with inotifywait or 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 FreeBSD pf run after NAT for outbound. By the time out on re0 is evaluated, the source is already re0's IP, so the <jails> membership check fails.
  • pass in log on bastille0 from <jails>. For jails with vnet=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)

  1. The nohup tcpdump dies on host reboot. Wrap in an /etc/rc.d/pflog-jail-egress (or a monit config) so it comes back.
  2. egress.log grows unbounded. Add a newsyslog.conf entry with a size cap (5000K B) and rotation count so it self-manages.
  3. pflog0 sees every jail's traffic. Currently one jail, but if agent-session-01 starts using its own outbound the streams commingle. Filter on src net 10.0.0.20/32 at capture time, or split per-jail scratchpads.

10.6. Composability with the rest of the stack

  • Jail's pf allowlist blocks direct api.anthropic.com to egress goes through LiteLLM only, so credit and rate are proxy-observed.
  • pkg -c from host is still available for jail lifecycle updates without opening more egress.
  • CLAUDE_CONFIG_DIR per jail means the operator's ~/.claude auth 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 permissive nat on … from <jails> to the default-deny allowlist above, once LiteLLM auth is provisioned for jail identities.
  • [ ] Move nexus pkg upgrade claude-code from 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 of misc/claude-code at ports HEAD (2.1.266) for the Fable 5.1 model (claude-fable-5-1), since FreeBSD-latest is stuck at 2.1.225.
  • [ ] Reboot nexus to reap a wedged 26-day-old find(1) (R< state, refused SIGKILL) 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)

  1. Install method: hydra previously used npm-installed Linux binary with symlink. Migrated to FreeBSD pkg (2026-09-12).
  2. 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-test jail before touching production host.
  • Repo priority: priority: 5 in 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