From 0c9971ecf8b970ca8a42d664aefb91504da6fc08 Mon Sep 17 00:00:00 2001 From: claude-hdb Date: Fri, 10 Jul 2026 15:00:36 +0000 Subject: [PATCH] Import claudebox: creds-free, trust-less Claude Code VMs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A CLI that mints trust-less, network-isolated Incus VMs with Claude Code installed. Boxes are strictly creds-free — the operator logs into Claude interactively inside; authenticated state is reused via snapshots. The tool knows nothing about projects; a repo ships an optional agent-facing .claudebox/ runbook that Claude reads. - bin/claudebox: new/shell/exec/snapshot/restore/down/start/rm/status; creds-free 'new' (fresh launch or clone via --from [/]). - cloud-init: global ~/.claude/CLAUDE.md self-describing the box + .claudebox/ runbook. - install.sh: curl-pipe-bash installer. - host/: Incus isolation stack (claudenet + claude-isolate ACL + claude-dev profile + firewall). - docs/: design + .claudebox/ convention. Initial canonical import (prototyped separately; re-homed onto the heavy-duty fork). Co-Authored-By: Claude Opus 4.8 --- .gitignore | 2 + README.md | 119 ++++++++++++++++++++++++++++++++ bin/claudebox | 89 ++++++++++++++++++++++++ cloud-init/user-data.yaml | 56 +++++++++++++++ docs/claudebox-design.md | 67 ++++++++++++++++++ docs/claudebox-recipe.md | 78 +++++++++++++++++++++ host/claudebox-firewall.service | 12 ++++ host/claudebox-firewall.sh | 34 +++++++++ host/setup-host.sh | 59 ++++++++++++++++ host/teardown-host.sh | 57 +++++++++++++++ install.sh | 72 +++++++++++++++++++ profiles/claude-dev.yaml | 14 ++++ 12 files changed, 659 insertions(+) create mode 100644 .gitignore create mode 100755 bin/claudebox create mode 100644 cloud-init/user-data.yaml create mode 100644 docs/claudebox-design.md create mode 100644 docs/claudebox-recipe.md create mode 100644 host/claudebox-firewall.service create mode 100644 host/claudebox-firewall.sh create mode 100755 host/setup-host.sh create mode 100755 host/teardown-host.sh create mode 100755 install.sh create mode 100644 profiles/claude-dev.yaml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..29f035a --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +secrets.env +*.agekey diff --git a/README.md b/README.md index 8b13789..5e93ad1 100644 --- a/README.md +++ b/README.md @@ -1 +1,120 @@ +# claudebox +A CLI to run **headless, trust-less Claude Code in throwaway VMs**. One command +mints a fresh, network-isolated Incus box with Claude Code installed. The box is +the product — you log in and work; destroying it loses nothing you didn't push. + +**Strictly creds-free.** A box ships with everything installed and **no** +credentials — no Claude token, no git PAT, nothing. You authenticate +interactively *inside* the box. The tool never stores or injects a secret. That +means there's nothing shared or committed, so it's safe for multiple operators +out of the box. + +**The tool knows nothing about your projects.** You just `git clone` inside a +box. A repo can ship an optional [`.claudebox/`](docs/claudebox-recipe.md) +runbook that Claude Code reads and acts on — there is no `install` step and no +host-run setup. See [docs/claudebox-design.md](docs/claudebox-design.md) for the +design rationale. + +## Install + +```sh +curl -fsSL https://raw.githubusercontent.com/claude-hdb/claudebox/main/install.sh | bash +``` + +Installs the tree to `~/.local/share/claudebox` and links `claudebox` onto your +`PATH`. Re-run any time to upgrade. (No `git clone` needed.) + +## One-time host setup (Ubuntu 24.04 / Debian 13) + +```sh +~/.local/share/claudebox/host/setup-host.sh # run twice if it adds you to incus-admin (re-login between) +``` + +Idempotent. Installs Incus and creates the isolation stack: the `claudenet` NAT +bridge, the `claude-isolate` ACL (drops all RFC1918/CGNAT/link-local egress), +the `claude-dev` profile, and firewall rules blocking instance → host. All rules +re-apply at boot via `claudebox-firewall.service` — no post-reboot ritual. If +the host lacks `dnsmasq-base` (Debian cloud images skip Recommends): +`sudo apt-get install -y dnsmasq-base`. + +## Quick start + +```sh +claudebox new --name work # mint a fresh, creds-free box (~10 min cold) +claudebox shell work # enter as the claude user +``` + +Inside the box, authenticate as needed: + +```sh +claude # then run /login — copy the URL (press c), open it + # in YOUR browser, paste the code back. No host CLI needed. +gh auth login # or drop a PAT in — your git credentials, your call +git clone https://github.com/you/project && cd project +claude # if the repo has .claudebox/, Claude reads it and sets up +``` + +## Log in once, reuse via snapshots + +Because every fresh box is creds-free, re-authenticating each time would be +toil. Snapshot an authenticated box and clone from it instead: + +```sh +claudebox snapshot work authed # checkpoint after you've logged in +claudebox new --name feature --from work/authed # clone the authed state into a new box +``` + +`--from` copies the whole box (Claude login, git creds, clones and all) while +preserving isolation. You can also `claudebox new --name x --from work` to clone +a box's live state, or roll a box back with `claudebox restore work authed`. + +## Commands + +``` +claudebox new --name [--from [/]] [--vm|--container] [--remote r] +claudebox shell # enter as the claude user +claudebox exec -- # run a command in the box +claudebox snapshot [label] # checkpoint (label defaults to manual-) +claudebox restore # roll back to a snapshot +claudebox down # stop (state kept; `start` resumes) +claudebox start # start a stopped box +claudebox rm # delete the box (irreversible; snapshot first) +claudebox status # list boxes +``` + +`new` fresh-launches from cloud-init, or with `--from` clones an existing box or +snapshot. VM mode (`--vm`, the default where `/dev/kvm` exists) is the trust-less +target; container mode (auto-fallback, `security.nesting=true`) is for hosts +without nested virt — weaker isolation, dev/test only. + +## Isolation + +Dedicated NAT bridge `claudenet` + Incus `claude-isolate` ACL dropping all +private-range egress, plus host-firewall rules that block instance → host +(including the host's public IPs). The box reaches the public internet and +nothing else. Entry is `incus exec` over the local socket only — **no inbound +path exists.** The VM is the trust boundary: Claude can run arbitrary code inside +and touch nothing you care about. + +## Recipes: the `.claudebox/` convention + +A repo that wants to be easy to stand up in a box ships an optional `.claudebox/` +folder — a runbook Claude reads and follows (install deps, start services, +template env, seed data, smoke-test). It is agent-facing documentation, not a +host-executed script. See [docs/claudebox-recipe.md](docs/claudebox-recipe.md). + +## Uninstall + +```sh +~/.local/share/claudebox/host/teardown-host.sh # boxes, network, ACL, profile, firewall +~/.local/share/claudebox/host/teardown-host.sh --purge-incus # ...and Incus itself +rm -rf ~/.local/share/claudebox ~/.local/bin/claudebox # the CLI +``` + +## Non-goals + +- **No unattended/CI bring-up.** The flow is interactive (log in, clone, ask + Claude). Reproducible-by-construction provisioning is out of scope. +- **No credential storage or injection by the tool.** Boxes are creds-free; + snapshots are the reuse mechanism, not a secrets store. diff --git a/bin/claudebox b/bin/claudebox new file mode 100755 index 0000000..e2f7575 --- /dev/null +++ b/bin/claudebox @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# claudebox — trust-less, isolated Incus VMs with Claude Code, creds-free. +# claudebox new --name [--from [/]] [--remote r] [--vm|--container] +# claudebox shell|down|start|status|rm +# claudebox exec -- +# claudebox snapshot [label] +# claudebox restore +# Boxes carry NO secrets: log into Claude interactively inside ('claude' then +# /login). Reuse an authenticated box via 'snapshot' + 'new --from'. +set -euo pipefail + +root="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)" +remote=""; mode="auto"; name=""; from=""; force=0 +cmd="${1:-help}"; shift || true +args=() +while [ $# -gt 0 ]; do + case "$1" in + --name) name="$2"; shift 2 ;; + --from) from="$2"; shift 2 ;; + --remote) remote="$2:"; shift 2 ;; + --vm) mode=vm; shift ;; + --container) mode=container; shift ;; + --force) force=1; shift ;; + --) shift; args+=("$@"); break ;; + *) args+=("$1"); shift ;; + esac +done + +die() { echo "claudebox: $*" >&2; exit 1; } +iname_of() { echo "$remote"claude-"$1"; } +need_name() { + [ "${#args[@]}" -ge 1 ] && [ -n "${args[0]}" ] || die "usage: claudebox $cmd " +} + +pick_mode() { + if [ "$mode" != auto ]; then echo "$mode"; return; fi + if [ -n "$remote" ] || [ -e /dev/kvm ]; then echo vm; else + echo "claudebox: no /dev/kvm — using container mode (weaker isolation, dev/test only)" >&2 + echo container + fi +} + +wait_agent() { + local n="$1" i + echo "claudebox: waiting for instance agent..." + for i in $(seq 1 90); do + if incus exec "$n" -- true >/dev/null 2>&1; then return; fi + [ "$i" -eq 90 ] && die "instance agent never came up (incus console $n to inspect)" + sleep 2 + done +} + +new() { + [ -n "$name" ] || die "usage: claudebox new --name [--from [/]]" + local instance; instance="$(iname_of "$name")" + if [ -n "$from" ]; then + local src="${from%%/*}" snap="" srcref + case "$from" in */*) snap="${from#*/}" ;; esac + srcref="$(iname_of "$src")"; [ -n "$snap" ] && srcref="$srcref/$snap" + incus copy "$srcref" "$instance" + incus start "$instance" + wait_agent "$instance" + echo "claudebox: cloned $srcref — isolation and Claude auth carry over from the source." + else + local m extra=(); m="$(pick_mode)" + # shellcheck disable=SC2054 # "root,size=60GiB" is a single incus argument + if [ "$m" = vm ]; then extra+=(--vm --device root,size=60GiB); else extra+=(--config security.nesting=true); fi + incus launch images:debian/13/cloud "$instance" --profile claude-dev \ + --config cloud-init.user-data="$(cat "$root/cloud-init/user-data.yaml")" \ + "${extra[@]}" + wait_agent "$instance" + echo "claudebox: waiting for phase-1 (cloud-init)..." + incus exec "$instance" -- cloud-init status --wait + fi + echo "claudebox: ready — 'claudebox shell $name'. Log into Claude inside: run 'claude' then /login." +} + +case "$cmd" in + new) new ;; + snapshot) need_name; label="${args[1]:-manual-$(date +%s)}"; incus snapshot create "$(iname_of "${args[0]}")" "$label"; echo "$label" ;; + restore) need_name; [ -n "${args[1]:-}" ] || die "usage: claudebox restore "; incus restore "$(iname_of "${args[0]}")" "${args[1]}" ;; + shell) need_name; incus exec "$(iname_of "${args[0]}")" -- sudo -u claude -i ;; + exec) need_name; incus exec "$(iname_of "${args[0]}")" -- sudo -u claude -i "${args[@]:1}" ;; + down) need_name; incus stop "$(iname_of "${args[0]}")" ;; + start) need_name; incus start "$(iname_of "${args[0]}")" ;; + rm) need_name; incus delete -f "$(iname_of "${args[0]}")"; echo "claudebox: removed $(iname_of "${args[0]}")" ;; + status) incus list "${remote}claude-" ;; + help|*) sed -n '2,9p' "$0" | sed 's/^# \{0,1\}//' ;; +esac diff --git a/cloud-init/user-data.yaml b/cloud-init/user-data.yaml new file mode 100644 index 0000000..3b19d7e --- /dev/null +++ b/cloud-init/user-data.yaml @@ -0,0 +1,56 @@ +#cloud-config +users: + - name: claude + shell: /bin/bash + sudo: "ALL=(ALL) NOPASSWD:ALL" + lock_passwd: true +write_files: + - path: /home/claude/.claude/CLAUDE.md + owner: "claude:claude" + permissions: '0644' + defer: true + content: | + # You are running inside a claudebox + + A claudebox is a trust-less, network-isolated, ephemeral VM created by the + `claudebox` CLI. Keep this context in mind: + + - **Creds-free by default.** The box starts with no Claude and no git + credentials. If you need to authenticate Claude, the operator runs `/login` + interactively. For git, the operator adds their own credentials (a PAT or + `gh auth login`). Never assume credentials are present; never ask for or + store secrets on disk beyond what the operator sets up. + - **Isolated.** The box reaches the public internet but nothing on the host or + local network. There is no inbound path. + - **Disposable.** Nothing here is backed up. State is discarded when the box is + removed; the operator persists work via git push and via `claudebox snapshot`. + - **Bootstrap runbook.** If the repository you are working in contains a + `.claudebox/` folder, read it as your setup runbook — how to install + dependencies, start services, template environment files, seed data, and + smoke-test — and follow it. It is documentation for you, not a script the + host runs. +package_update: true +packages: + - git + - curl + - ca-certificates + - gnupg + - ripgrep + - jq + - tmux + - age + - unzip + - build-essential + - zsh +runcmd: + - curl -fsSL https://get.docker.com | sh + - usermod -aG docker claude + - curl -fsSL https://deb.nodesource.com/setup_22.x | bash - + - apt-get install -y nodejs + - sudo -u claude bash -lc 'curl -fsSL https://claude.ai/install.sh | bash' + - echo 'export PATH="$HOME/.local/bin:$PATH"' >> /home/claude/.bashrc + - chsh -s /usr/bin/zsh claude + - sudo -u claude bash -lc 'RUNZSH=no CHSH=no sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"' + - echo 'export PATH="$HOME/.local/bin:$PATH"' >> /home/claude/.zshrc + - echo 'set -g mouse on' > /home/claude/.tmux.conf + - chown claude:claude /home/claude/.tmux.conf diff --git a/docs/claudebox-design.md b/docs/claudebox-design.md new file mode 100644 index 0000000..67d5fac --- /dev/null +++ b/docs/claudebox-design.md @@ -0,0 +1,67 @@ +# claudebox design + +`claudebox` is a CLI that mints and manages **trust-less, network-isolated VMs +with Claude Code installed**. It is infrastructure, not a project provisioner. + +See issue #3 for the full reframe and rationale. This doc captures the durable +design decisions. + +## Principle: separate the tool from the agent + +- **The tool** mints isolated boxes with Claude installed but **unauthenticated**. + It knows nothing about projects, secrets, recipes, or memory. +- **The agent** (Claude Code, inside the box) reads an optional `.claudebox/` + runbook in a cloned repo and acts on it. The recipe's consumer is the + reasoning agent, not host machinery. + +## Boxes are strictly creds-free + +`claudebox new --name ` launches a blank box: everything installed, **no** +git credentials and **no** Claude credentials. The operator authenticates +interactively *inside* the box: + +- **Claude** — `claude` → `/login` (paste-a-code OAuth: copy the URL, open it in + your own browser, paste the code back). Works because the box is outbound-only; + the tool never handles a token. +- **Git** — the operator adds their own PAT / `gh auth login` inside the box. + +The tool stores and injects **no** credentials, ever. This dissolves the +multi-user problem: nothing shared, nothing committed. + +## Snapshots are the reuse mechanism + +Re-authing every fresh box would be toil, so authenticated state is reused via +snapshots, not a secrets store: + +- `claudebox snapshot [label]` — checkpoint after login + clone. +- `claudebox new --name --from [/]` — clone an existing box + or snapshot (authed state and all). Isolation is preserved: the clone keeps + the `claude-dev` profile + `claudenet` + ACL. +- `claudebox restore ` — roll a box back to a checkpoint. + +Log in once → snapshot → spin up authed boxes from it. + +## The box announces itself to the agent + +cloud-init installs a global `~/.claude/CLAUDE.md` in every box telling Claude it +is running in a claudebox (trust-less, ephemeral, creds-free) and to treat a +repo's `.claudebox/` folder as its bootstrap runbook. No "tell it" step, no host +execution. + +## `.claudebox/` is optional, agent-facing documentation + +Not host-executed shell. A repo that wants to be easy to stand up in a sandbox +ships a runbook (prose + optional scripts the agent may run). A repo that does +not, you set up by hand. The tool enforces no contract; there is no `install`. + +## Isolation (unchanged) + +Dedicated NAT bridge `claudenet` + Incus `claude-isolate` ACL dropping all +RFC1918/CGNAT/link-local egress, plus host-firewall rules blocking instance → +host. The instance reaches the internet and nothing else. Entry is `incus exec` +over the local socket — no inbound path. The VM is the trust boundary. + +## Non-goals + +- No unattended/CI bring-up — the flow is interactive. +- No credential storage or injection by the tool. diff --git a/docs/claudebox-recipe.md b/docs/claudebox-recipe.md new file mode 100644 index 0000000..e79ee6a --- /dev/null +++ b/docs/claudebox-recipe.md @@ -0,0 +1,78 @@ +# The `.claudebox/` convention + +`claudebox` mints trust-less, creds-free, isolated VMs with Claude Code already +installed (`claudebox new/shell/snapshot/restore/exec/down/start/rm/status`). The +tool knows **nothing** about your project. There is no `install` step and no +host-run setup script. + +A project makes itself easy to stand up inside a box by shipping an optional +`.claudebox/` folder. This folder is **agent-facing documentation** — read and +acted on by Claude Code (the reasoning agent) running inside the box. It is not +shell that the host executes. + +## What it is / what it is not + +- **Optional.** No `.claudebox/` is a perfectly valid state. +- **Agent-facing.** You are writing instructions to a reasoning agent, not a + machine. Prose is fine; the agent adapts. +- **Not a host contract.** The host never parses, sources, or runs anything in + here. There is no enforced schema and no required filenames. +- Old model: a host-executed `.devbox/setup.sh`. New model: a runbook the agent + reads and decides how to act on. + +## How it's consumed + +Every box ships a global `~/.claude/CLAUDE.md` telling Claude it is inside a +claudebox and to treat a repo's `.claudebox/` folder as its bootstrap runbook. +So the whole flow is: + +``` +claudebox new # get a box +claudebox shell # get in +git clone && cd +claude # Claude reads .claudebox/ and brings the project up +``` + +The operator can also just say: *"set this project up per .claudebox"*. + +## Suggested contents (all optional) + +Author everything here for a reasoning agent. + +- **`.claudebox/SETUP.md`** — the prose runbook. Prerequisites, how to install + deps, how to start services, how to template the env, how to seed data, and + how to smoke-test. Written as instructions to Claude. +- **Helper scripts** (e.g. `.claudebox/dev-up.sh`) that the runbook tells Claude + to run. Claude decides to run them; the host never does. +- **`.claudebox/env.template`** — example env the runbook explains how to fill. + Staging values the operator pastes in. **Never commit real secrets.** +- **`.claudebox/compose.yml`** — optional services the runbook starts. + +## Worked example + +A minimal `.claudebox/SETUP.md` for a Node + Postgres app: + +```markdown +# Setup + +This is a Node service backed by Postgres. + +1. Install deps: `npm ci` +2. Start Postgres: `docker compose -f .claudebox/compose.yml up -d` +3. Create the env file: copy `.claudebox/env.template` to `.env` and ask the + operator to fill in `DATABASE_URL` and `API_KEY` (staging values). +4. Run migrations: `npm run migrate` +5. Start the app: `npm run dev` +6. Smoke-test: `curl -sf localhost:3000/health` should return `{"ok":true}`. +``` + +That's it — Claude reads it top to bottom and adapts if reality differs. + +## Guidance + +- **Keep it declarative and resilient.** State intent and steps; let the agent + adapt when the repo has drifted. Don't hard-code brittle assumptions. +- **Never put real credentials in `.claudebox/`.** Templates and staging + placeholders only. The operator pastes real values at runtime. +- **No `.claudebox/` is fine.** The operator can stand the project up by hand, + or let Claude infer the steps from the repo's `README` / `CLAUDE.md`. diff --git a/host/claudebox-firewall.service b/host/claudebox-firewall.service new file mode 100644 index 0000000..bac03e0 --- /dev/null +++ b/host/claudebox-firewall.service @@ -0,0 +1,12 @@ +[Unit] +Description=Re-apply claudebox host firewall rules +# After docker so DOCKER-USER exists; still fine if docker is absent. +After=network.target incus.service docker.service +Wants=network.target + +[Service] +Type=oneshot +ExecStart=/usr/local/sbin/claudebox-firewall + +[Install] +WantedBy=multi-user.target diff --git a/host/claudebox-firewall.sh b/host/claudebox-firewall.sh new file mode 100644 index 0000000..5ffcc6e --- /dev/null +++ b/host/claudebox-firewall.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Apply the claudebox host-firewall rules. Idempotent; runs as root. +# Invoked by setup-host.sh at install time and by claudebox-firewall.service +# at every boot (UFW rules persist on their own; the nft fallback table and +# Docker's DOCKER-USER rules are runtime-only and need re-applying). +set -euo pipefail + +GW=10.87.0.1 +NET=claudenet + +if command -v ufw >/dev/null && ufw status 2>/dev/null | grep -q "Status: active"; then + if ! ufw status | grep "on $NET" | grep -q "DENY"; then + ufw insert 1 deny in on "$NET" + ufw insert 1 allow in on "$NET" to "$GW" port 53 proto tcp + ufw insert 1 allow in on "$NET" to "$GW" port 53 proto udp + ufw insert 1 allow in on "$NET" to any port 67 proto udp + ufw route allow in on "$NET" + fi +else + # No UFW: protect the host's own sockets with a dedicated nft table. + if ! nft list table inet claudebox >/dev/null 2>&1; then + nft add table inet claudebox + nft 'add chain inet claudebox input { type filter hook input priority -5 ; }' + nft add rule inet claudebox input iifname "$NET" udp dport '{ 53, 67 }' accept + nft add rule inet claudebox input iifname "$NET" tcp dport 53 accept + nft add rule inet claudebox input iifname "$NET" drop + fi +fi + +# Docker rewrites FORWARD policy to DROP; DOCKER-USER is its escape hatch. +if command -v docker >/dev/null && iptables -L DOCKER-USER -n >/dev/null 2>&1; then + iptables -C DOCKER-USER -i "$NET" -j ACCEPT 2>/dev/null || iptables -I DOCKER-USER -i "$NET" -j ACCEPT + iptables -C DOCKER-USER -o "$NET" -j ACCEPT 2>/dev/null || iptables -I DOCKER-USER -o "$NET" -j ACCEPT +fi diff --git a/host/setup-host.sh b/host/setup-host.sh new file mode 100755 index 0000000..b72efe8 --- /dev/null +++ b/host/setup-host.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# One-time host setup: install Incus, create the isolated network + ACL and +# the claude-dev profile. Idempotent. Ubuntu 24.04 / Debian 13. +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +if ! command -v incus >/dev/null; then + sudo apt-get update + sudo apt-get install -y incus +fi + +if ! id -nG "$USER" | grep -qw incus-admin; then + sudo usermod -aG incus-admin "$USER" + echo "NOTE: added $USER to incus-admin — re-login (or 'sg incus-admin') and re-run." + exit 0 +fi + +# Storage pool + base config (safe to re-run; init is a no-op if configured) +incus storage show default >/dev/null 2>&1 || incus admin init --minimal + +# Isolated NAT network. IPv6 off: one less egress path to reason about. +incus network show claudenet >/dev/null 2>&1 || incus network create claudenet \ + ipv4.address=10.87.0.1/24 ipv4.nat=true ipv6.address=none + +# ACL: default egress allow (internet), explicit drops for private space. +# Gateway carve-out first so instance DNS (dnsmasq on 10.87.0.1) survives. +if ! incus network acl show claude-isolate >/dev/null 2>&1; then + incus network acl create claude-isolate + incus network acl rule add claude-isolate egress action=allow destination=10.87.0.1/32 + incus network acl rule add claude-isolate egress action=drop destination=10.0.0.0/8 + incus network acl rule add claude-isolate egress action=drop destination=172.16.0.0/12 + incus network acl rule add claude-isolate egress action=drop destination=192.168.0.0/16 + incus network acl rule add claude-isolate egress action=drop destination=169.254.0.0/16 + incus network acl rule add claude-isolate egress action=drop destination=100.64.0.0/10 +fi +incus network set claudenet security.acls=claude-isolate \ + security.acls.default.egress.action=allow \ + security.acls.default.ingress.action=drop + +# --- Firewall coexistence --------------------------------------------------- +# Hosts running UFW (INPUT drop) and/or Docker (FORWARD drop) silently eat +# claudenet traffic. Punch minimal, ordered holes; the Incus ACL still layers +# on top. The trailing deny also blocks instance -> host's own (public) IPs, +# which the RFC1918-only ACL cannot express. Rules live in +# claudebox-firewall.sh; a boot-time systemd unit re-applies the runtime-only +# parts (nft table, DOCKER-USER) after every reboot. +sudo install -m 755 "$here/host/claudebox-firewall.sh" /usr/local/sbin/claudebox-firewall +sudo install -m 644 "$here/host/claudebox-firewall.service" /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now claudebox-firewall.service + +# Profile +if ! incus profile show claude-dev >/dev/null 2>&1; then + incus profile create claude-dev +fi +incus profile edit claude-dev < "$here/profiles/claude-dev.yaml" + +echo "Host ready. Launch with: claudebox new --name " diff --git a/host/teardown-host.sh b/host/teardown-host.sh new file mode 100755 index 0000000..4350da9 --- /dev/null +++ b/host/teardown-host.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Reverse everything host/setup-host.sh created: all claude-* instances, the +# claudenet network + ACL, the claude-dev profile, and the firewall rules. +# Usage: ./host/teardown-host.sh [--purge-incus] +# --purge-incus also apt-purge Incus itself (skipped if non-claudebox +# instances still exist on this host) +set -euo pipefail + +purge=false +[ "${1:-}" = "--purge-incus" ] && purge=true + +echo "This removes ALL claude-* instances (uncommitted work in them is lost)," +echo "the claudenet network/ACL/profile, and the claudebox firewall rules." +$purge && echo "Incus itself will also be uninstalled (--purge-incus)." +read -rp "Continue? [y/N] " a +case "$a" in y|Y) ;; *) echo "aborted"; exit 1 ;; esac + +# Instances +for i in $(incus list -f csv -c n | grep '^claude-' || true); do + echo "deleting instance $i" + incus delete -f "$i" +done + +incus profile delete claude-dev 2>/dev/null || true +incus network delete claudenet 2>/dev/null || true +incus network acl delete claude-isolate 2>/dev/null || true + +# Boot-persistence unit +sudo systemctl disable --now claudebox-firewall.service 2>/dev/null || true +sudo rm -f /etc/systemd/system/claudebox-firewall.service /usr/local/sbin/claudebox-firewall +sudo systemctl daemon-reload + +# Firewall crumbs — UFW rules mentioning claudenet (numbers shift after each +# delete, so re-scan and remove the first match until none remain) +if command -v ufw >/dev/null && sudo ufw status 2>/dev/null | grep -q "Status: active"; then + while sudo ufw status numbered | grep -q "on claudenet"; do + n="$(sudo ufw status numbered | grep -m1 "on claudenet" | sed -E 's/^\[ *([0-9]+)\].*/\1/')" + sudo ufw --force delete "$n" + done +fi +sudo nft delete table inet claudebox 2>/dev/null || true +if command -v docker >/dev/null; then + sudo iptables -D DOCKER-USER -i claudenet -j ACCEPT 2>/dev/null || true + sudo iptables -D DOCKER-USER -o claudenet -j ACCEPT 2>/dev/null || true +fi + +if $purge; then + remaining="$(incus list -f csv 2>/dev/null | wc -l)" + if [ "$remaining" -gt 0 ]; then + echo "NOTE: $remaining non-claudebox instance(s) remain on this host — leaving Incus installed." + else + sudo apt-get purge -y incus + sudo apt-get autoremove -y + fi +fi + +echo "Teardown complete. (Your ~/.local/bin/claudebox symlink and ~/.local/share/claudebox remain — remove by hand if wanted.)" diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..44aad1b --- /dev/null +++ b/install.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +set -euo pipefail + +# claudebox installer — intended for: curl -fsSL .../install.sh | bash +# +# Downloads the claudebox repo tarball, installs the whole tree under +# $DEST, and puts a `claudebox` symlink on PATH via $BINDIR. + +REPO="${CLAUDEBOX_REPO:-claude-hdb/claudebox}" +REF="${CLAUDEBOX_REF:-main}" +DEST="${CLAUDEBOX_HOME:-$HOME/.local/share/claudebox}" +BINDIR="${CLAUDEBOX_BIN:-$HOME/.local/bin}" + +log() { printf 'claudebox-install: %s\n' "$*"; } +warn() { printf 'claudebox-install: WARNING: %s\n' "$*" >&2; } +die() { printf 'claudebox-install: ERROR: %s\n' "$*" >&2; exit 1; } + +# --- prerequisites --------------------------------------------------------- +command -v curl >/dev/null 2>&1 || die "curl is required but was not found. Please install curl and re-run." +command -v tar >/dev/null 2>&1 || die "tar is required but was not found. Please install tar and re-run." + +# --- temp workspace -------------------------------------------------------- +TMPDIR="$(mktemp -d)" +cleanup() { rm -rf "$TMPDIR"; } +trap cleanup EXIT + +URL="https://github.com/$REPO/archive/refs/heads/$REF.tar.gz" + +log "installing claudebox ($REPO@$REF)" +log "downloading $URL" +curl -fsSL "$URL" -o "$TMPDIR/claudebox.tar.gz" \ + || die "failed to download $URL" + +log "extracting archive" +tar -xzf "$TMPDIR/claudebox.tar.gz" -C "$TMPDIR" \ + || die "failed to extract archive" + +# GitHub archives extract to a single top-level dir like claudebox-/ +EXTRACTED="$(find "$TMPDIR" -maxdepth 1 -type d -name 'claudebox-*' | head -n1)" +[ -n "$EXTRACTED" ] || die "could not find extracted claudebox-* directory in archive" +[ -f "$EXTRACTED/bin/claudebox" ] || die "archive does not contain bin/claudebox — is $REPO@$REF correct?" + +# --- atomically replace $DEST --------------------------------------------- +log "installing into $DEST" +rm -rf "$DEST" +mkdir -p "$(dirname "$DEST")" +mv "$EXTRACTED" "$DEST" + +chmod +x "$DEST/bin/claudebox" + +# --- put claudebox on PATH ------------------------------------------------- +mkdir -p "$BINDIR" +ln -sf "$DEST/bin/claudebox" "$BINDIR/claudebox" +log "linked $BINDIR/claudebox -> $DEST/bin/claudebox" + +# --- PATH check ------------------------------------------------------------ +case ":$PATH:" in + *":$BINDIR:"*) : ;; + *) + log "note: $BINDIR is not on your PATH." + log " add this to your shell rc (e.g. ~/.bashrc or ~/.zshrc):" + log " export PATH=\"$BINDIR:\$PATH\"" + ;; +esac + +# --- environment check ----------------------------------------------------- +if ! command -v incus >/dev/null 2>&1; then + warn "incus was not found. claudebox needs Incus on the host." + warn " run the one-time host setup: $DEST/host/setup-host.sh" +fi + +log "done — try: claudebox new --name test" diff --git a/profiles/claude-dev.yaml b/profiles/claude-dev.yaml new file mode 100644 index 0000000..4b4850d --- /dev/null +++ b/profiles/claude-dev.yaml @@ -0,0 +1,14 @@ +name: claude-dev +description: Trust-less claudebox (resources + isolated NIC) +config: + limits.cpu: "4" + limits.memory: 8GiB +devices: + eth0: + type: nic + network: claudenet + name: eth0 + root: + type: disk + pool: default + path: /