forked from heavy-duty/box
Import claudebox: creds-free, trust-less Claude Code VMs
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 <src>[/<snap>]). - 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 <noreply@anthropic.com>
This commit is contained in:
parent
ec41ade3fc
commit
0c9971ecf8
12 changed files with 659 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
secrets.env
|
||||
*.agekey
|
||||
119
README.md
119
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 <box> [--from <src>[/<snap>]] [--vm|--container] [--remote r]
|
||||
claudebox shell <box> # enter as the claude user
|
||||
claudebox exec <box> -- <cmd...> # run a command in the box
|
||||
claudebox snapshot <box> [label] # checkpoint (label defaults to manual-<epoch>)
|
||||
claudebox restore <box> <snap> # roll back to a snapshot
|
||||
claudebox down <box> # stop (state kept; `start` resumes)
|
||||
claudebox start <box> # start a stopped box
|
||||
claudebox rm <box> # 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.
|
||||
|
|
|
|||
89
bin/claudebox
Executable file
89
bin/claudebox
Executable file
|
|
@ -0,0 +1,89 @@
|
|||
#!/usr/bin/env bash
|
||||
# claudebox — trust-less, isolated Incus VMs with Claude Code, creds-free.
|
||||
# claudebox new --name <box> [--from <src>[/<snap>]] [--remote r] [--vm|--container]
|
||||
# claudebox shell|down|start|status|rm <box>
|
||||
# claudebox exec <box> -- <cmd...>
|
||||
# claudebox snapshot <box> [label]
|
||||
# claudebox restore <box> <snapshot>
|
||||
# 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 <box>"
|
||||
}
|
||||
|
||||
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 <box> [--from <src>[/<snap>]]"
|
||||
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 <box> <snapshot>"; 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
|
||||
56
cloud-init/user-data.yaml
Normal file
56
cloud-init/user-data.yaml
Normal file
|
|
@ -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
|
||||
67
docs/claudebox-design.md
Normal file
67
docs/claudebox-design.md
Normal file
|
|
@ -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 <n>` 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 <n> [label]` — checkpoint after login + clone.
|
||||
- `claudebox new --name <n2> --from <src>[/<snapshot>]` — clone an existing box
|
||||
or snapshot (authed state and all). Isolation is preserved: the clone keeps
|
||||
the `claude-dev` profile + `claudenet` + ACL.
|
||||
- `claudebox restore <n> <snapshot>` — 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.
|
||||
78
docs/claudebox-recipe.md
Normal file
78
docs/claudebox-recipe.md
Normal file
|
|
@ -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 <repo> && cd <repo>
|
||||
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`.
|
||||
12
host/claudebox-firewall.service
Normal file
12
host/claudebox-firewall.service
Normal file
|
|
@ -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
|
||||
34
host/claudebox-firewall.sh
Normal file
34
host/claudebox-firewall.sh
Normal file
|
|
@ -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
|
||||
59
host/setup-host.sh
Executable file
59
host/setup-host.sh
Executable file
|
|
@ -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 <box>"
|
||||
57
host/teardown-host.sh
Executable file
57
host/teardown-host.sh
Executable file
|
|
@ -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.)"
|
||||
72
install.sh
Executable file
72
install.sh
Executable file
|
|
@ -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-<ref>/
|
||||
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"
|
||||
14
profiles/claude-dev.yaml
Normal file
14
profiles/claude-dev.yaml
Normal file
|
|
@ -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: /
|
||||
Loading…
Reference in a new issue