Claude Code has no native OpenBSD build — the npm package only ships prebuilt binaries
for darwin-*, linux-* and win32-* targets, so npm install -g @anthropic-ai/claude-code
simply has nowhere to fetch a binary from on OpenBSD. There's no workaround for that:
it's a hard platform gap, not a missing dependency.
This repo documents a working setup that runs Claude Code on top of OpenBSD anyway,
using OpenBSD's own virtualization (vmd) to host a Linux guest — and treats OpenBSD's
security model (not Linux's) as the actual perimeter around the agent. It's the
architecture behind a real, long-running "resident agent" I've had operating unattended
on a spare laptop for several days at time of writing, not a one-off experiment.
If the agent has to run inside a Linux VM either way, why not just run Linux as the host and skip a layer? Because the layer being skipped is where most of the actual safety comes from:
pf, OpenBSD's stateful firewall, with a default-deny posture and per-service rate-limiting (SSH brute-force protection is a few lines ofpf.conf, not a separate daemon).doasinstead ofsudo— a much smaller attack surface for privilege escalation, and (as used here) trivially easy to scope to a handful of named commands instead of "yes/no to root".pledge/unveilfor the host's own daemons and scripts — a process that callspledge("stdio rpath", NULL)cannot open a socket even if compromised, no matter what code runs inside it.vmd/vmm, OpenBSD's own hypervisor — one fewer piece of C code (no QEMU/KVM) in the host's trusted computing base for the VM that does the actual "day job" networking.- A famously small, security-audited base system,
W^Xenforced by default, and a release cadence built around "remove the thing that was wrong" rather than "add a flag to disable it".
None of that protects the inside of the Linux VM. What it does is make the host that VM runs on a much smaller, harder target — which matters a lot when the thing running inside is an autonomous agent with real credentials.
┌─────────────────────────────────────────────────────────────────┐
│ Physical hardware │
│ ┌───────────────────────────────────────────────────────────────┐│
│ │ OpenBSD (host) ││
│ │ pf (firewall, rate-limiting) · doas (scoped privilege) ││
│ │ ┌─────────────────────────────────────────────────────────┐ ││
│ │ │ vmd/vmm guest: Alpine Linux │ ││
│ │ │ ┌───────────────────────────────────────────────────┐ │ ││
│ │ │ │ Docker container (node:22-slim) │ │ ││
│ │ │ │ --cap-drop=ALL --security-opt=no-new-privileges │ │ ││
│ │ │ │ unprivileged uid, no shell privilege escalation │ │ ││
│ │ │ │ │ │ ││
│ │ │ │ Claude Code (this is what actually runs) │ │ ││
│ │ │ └───────────────────────────────────────────────────┘ │ ││
│ │ └─────────────────────────────────────────────────────────┘ ││
│ └───────────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────┘
Three independent containment layers, each backed by a different technology, each one
useless on its own but genuinely additive together: a Docker escape still lands you in
a bare Alpine VM with no host access; a VM escape still lands you on an OpenBSD host
where pf/doas limit what an attacker (or a misbehaving agent) can actually do next.
# vm.conf
vm "claude" {
memory 4G
disk /home/vm/claude.qcow2 format qcow2
local interface
owner someuser
}
Install Alpine Linux into it the normal way (alpine-virt.iso via vmctl), then inside
the guest:
apk add docker docker-cli-compose
rc-update add docker default
rc-service docker startFROM node:22-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
git curl openssh-client gnupg jq python3 iproute2 iputils-ping \
vim-tiny nano openssl age ca-certificates less procps \
&& rm -rf /var/lib/apt/lists/* \
&& npm install -g @anthropic-ai/claude-code \
&& groupadd -g 1001 claude \
&& useradd -m -u 1001 -g 1001 -s /bin/bash claude \
&& chown -R claude:claude /usr/local/lib/node_modules /usr/local/bin /usr/local/share
USER claude
WORKDIR /home/claude
ENV HOME=/home/claude
ENTRYPOINT []
CMD ["claude"]The chown at the end matters more than it looks: without it, npm install -g (done as
root during the build) leaves the global package owned by root, and Claude Code's
self-updater silently fails with a permission error every time it runs as the
unprivileged claude user afterward.
docker run -it --rm --cap-drop=ALL --security-opt=no-new-privileges \
--memory=2g --cpus=1 \
--name my-agent \
-v /root/agent-workspace:/home/claude/workspace \
-v /root/.claude:/home/claude/.claude \
-v /root/.claude.json:/home/claude/.claude.json \
-w /home/claude/workspace \
claude-code:latest claude--cap-drop=ALL --security-opt=no-new-privileges is the part doing the actual work —
this is a normal unprivileged container, not a --privileged one, so a compromised
process inside it can't do anything a normal Linux user process couldn't (no raw
sockets, no ptrace of other processes, no mount, no new-privilege escalation via
setuid binaries).
If the agent needs to actually administer the OpenBSD host (check service status,
restart one specific daemon, read logs) — not just sit isolated forever — resist the
urge to just hand it a root SSH key. That defeats most of what the isolation above
bought you: from inside the container, ssh root@host and doas anything are
functionally the same "just trust it completely" move.
Instead, scope it properly, one purpose-built piece at a time:
- A dedicated user on the OpenBSD host, key-only, one per agent:
useradd -m -s /bin/ksh agentname usermod -p '*' agentname # lock password login
- A dedicated SSH keypair, not reused from anywhere else, stored inside the
container's persistent bind-mount (not
/root/.sshinside the container, which disappears with--rm). - Purpose-built wrapper scripts in
/usr/local/sbin/, one per action, each validating its own inputs — not adoasgrant on the raw binary:#!/bin/ksh # /usr/local/sbin/agent-restart-service.sh ALLOW="httpd smtpd" SVC="$1" for s in $ALLOW; do [ "$SVC" = "$s" ] && exec rcctl restart "$SVC"; done echo "refused: $SVC not in allowlist ($ALLOW)" >&2; exit 1
doas.confgranting exactly those wrapper paths, nothing else:permit nopass agentname cmd /usr/local/sbin/agent-restart-service.sh permit nopass agentname cmd /usr/local/sbin/agent-status.sh
Test all three cases before trusting it: an allowed command works, a disallowed command
gets rejected by doas itself (wrong user/command), and an allowed script rejects a
disallowed argument on its own. This pattern is cheap enough to repeat per-agent that
"one container, one scoped user, one set of wrapper scripts" scales fine to several
agents on the same host without any of them being able to touch each other's scope.
docker run -it --rm inside a tmux session run by hand doesn't survive the host
rebooting. An OpenRC service that just makes sure the tmux session exists closes that
gap:
#!/sbin/openrc-run
name="claude-agent"
depend() { need net docker; after docker; }
start() {
ebegin "Starting claude-agent tmux session"
tmux has-session -t claude-agent 2>/dev/null || \
tmux new-session -d -s claude-agent /root/scripts/run-agent.sh
eend $?
}
stop() {
ebegin "Stopping claude-agent"
tmux kill-session -t claude-agent 2>/dev/null
eend 0
}Wrap the actual docker run in a while true; do docker run ...; sleep 5; done loop in
run-agent.sh and it also survives the agent process itself crashing, not just a host
reboot — each iteration is a fresh container, but the credentials and workspace persist
via the bind mounts either way.
The isolation described above only matters if the OpenBSD host itself stays healthy. Three concrete pieces, all cheap to set up, that pull real weight:
pf rate-limiting on the one port actually facing anything (SSH, typically):
table <bruteforce> persist
block return log all
pass in quick on egress proto tcp to port 22 keep state \
(max-src-conn-rate 5/15, overload <bruteforce> flush global)
Five connection attempts in fifteen seconds from one source and pf starts silently
dropping it — no separate daemon, no log-parsing, just the firewall's own state table.
One real gotcha worth knowing before you rely on this: if that host is itself sitting
behind NAT relative to whoever's connecting (a VPN gateway, another box doing
port-forwarding), every legitimate connection can arrive looking like the same source
IP as everyone else behind that NAT hop — and this exact rule will cheerfully rate-limit
out your own legitimate traffic along with an actual attacker's. Put this kind of
per-source-IP limiting as close to the real client as the topology allows, not on
whichever box happens to be easiest to edit.
A weekly patch-status check that only reports, never applies on its own:
#!/bin/ksh
# cron: 0 3 * * 0 (Sunday 03:00)
pending=$(syspatch -c)
[ -n "$pending" ] && echo "$pending" | mail -s "syspatch pending on $(hostname)" rootsyspatch -c lists what's available without touching anything; deciding whether to
actually run syspatch (and reboot to load a patched kernel) stays a deliberate human
call — but the finding out that something needs attention part shouldn't depend on
anyone remembering to check by hand. The one syspatch worth never letting slide
silently: anything patching vmd/vmm itself, since that's the hypervisor everything
else in this setup runs on top of.
Treating the VM's tap interface as a trust boundary, deliberately, in one line:
set skip on { lo tap0 }
Traffic on the interface connecting to a VM you control end-to-end doesn't need to pass
through the same filter rules as traffic from the actual outside world — pf lets you
say that explicitly instead of it being an accidental side effect of rule ordering.
- npm auto-update breaks by default the way described above (root-owned global package, non-root runtime user) — fix it once in the Dockerfile, not per-container.
- Concurrent instances sharing the same
.claude.json(e.g. one persistent agent plus someone launching an ad-hoc interactive session against the same credentials) can corrupt that file. Give a long-running resident agent its own credentials directory if anything else might also connect as the same account. - This whole approach assumes you're fine with the agent's actual runtime being Linux (inside the VM) even though the host is OpenBSD. If you need it to run directly on OpenBSD's own userland with no Linux anywhere in the stack, this isn't that — that would require Claude Code itself to ship an OpenBSD target, which it doesn't.
Built and tested as part of a home-lab cluster project; the scoped-SSH pattern and the OpenRC persistence unit are original to that project, not documented anywhere else I could find at the time.