Skip to content

About

Running Claude Code on OpenBSD via vmd + Alpine Linux + Docker, with OpenBSD's own security model (pf/doas/pledge) as the real perimeter

Topics

Resources

Stars

4 stars

Watchers

0 watching

Forks

Latest commit

 

History

3 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 

Repository files navigation

Claude Code on OpenBSD

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.

Why bother with OpenBSD as the host at all?

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 of pf.conf, not a separate daemon).
  • doas instead of sudo — 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/unveil for the host's own daemons and scripts — a process that calls pledge("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^X enforced 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.

Architecture

┌─────────────────────────────────────────────────────────────────┐
│ 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.

Setup

1. The VM

# 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 start

2. The container image

FROM 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).

3. Giving it scoped access back to the host

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:

  1. A dedicated user on the OpenBSD host, key-only, one per agent:
    useradd -m -s /bin/ksh agentname
    usermod -p '*' agentname   # lock password login
  2. A dedicated SSH keypair, not reused from anywhere else, stored inside the container's persistent bind-mount (not /root/.ssh inside the container, which disappears with --rm).
  3. Purpose-built wrapper scripts in /usr/local/sbin/, one per action, each validating its own inputs — not a doas grant 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
  4. doas.conf granting 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.

4. Surviving a reboot

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.

5. Hardening the host it all sits on

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)" root

syspatch -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.

Known rough edges

  • 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.

Credits

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.

License

MIT

About

Running Claude Code on OpenBSD via vmd + Alpine Linux + Docker, with OpenBSD's own security model (pf/doas/pledge) as the real perimeter

Topics

Resources

Stars

4 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors