feat: 'box expose <box> <port>' — a deliberate, loopback-only door to a dev server

The 'no inbound path' contract is one notch too absolute for the tool's
own flagship workflow: coding in a box, a dev server on :3000, and no
way to open it in your browser. expose is the deliberate un-screwing.

- Loopback only, always: the host side listens on 127.0.0.1, never
  0.0.0.0 — no other machine can reach the box; only this host gets a
  door. No flag widens it (that is the escape hatch's job).
- A verb, per-port, reversible, visible: each exposure is a named proxy
  device (expose-<port>); --list and box info show it, --remove undoes
  it. A box with a hole says so.
- Mechanism (VMs): an Incus proxy device forwards host loopback to the
  box's ip:port, plus a SCOPED ingress ACL allow (this box's ip + this
  port only) so the forkproxy's connection survives the default drop —
  the drill decides whether that allow is needed or redundant. The
  in-box server must listen on 0.0.0.0 (a VM's forwarder reaches it over
  the network); inside an isolated box that is safe.

Drill phase E: start a detached listener in a box, expose it, prove the
HOST loopback reaches it, prove a NON-exposed port is still dropped (A7
survives), prove --remove shuts the door.

Closes #55
This commit is contained in:
claude-hdb 2026-07-14 16:07:17 +00:00
parent 9b3522e4ee
commit de6467a728
3 changed files with 168 additions and 1 deletions

View file

@ -128,6 +128,7 @@ box rename <box> <new> # rename a box (stop it first)
box down <box> # stop (state kept; `start` resumes) box down <box> # stop (state kept; `start` resumes)
box start <box> # start a stopped box box start <box> # start a stopped box
box rm <box> [--force] # delete the box + its snapshots (asks first) box rm <box> [--force] # delete the box + its snapshots (asks first)
box expose <box> <port> # forward a box port to host loopback — see a dev server
box incus <box> -- <args...> # escape hatch: any incus command, box resolved box incus <box> -- <args...> # escape hatch: any incus command, box resolved
box doctor [--fix|--pin-dns] # is this host fit to mint boxes? diagnose from ground truth box doctor [--fix|--pin-dns] # is this host fit to mint boxes? diagnose from ground truth
box status # deprecated alias for `list` box status # deprecated alias for `list`
@ -185,7 +186,9 @@ enforces it, layer by layer:
host-level VPN don't resolve inside a box either. host-level VPN don't resolve inside a box either.
- **Host firewall** — instance → host is dropped except DNS/DHCP, including - **Host firewall** — instance → host is dropped except DNS/DHCP, including
the host's public IPs. Entry is `incus exec` over the local socket only — the host's public IPs. Entry is `incus exec` over the local socket only —
**no inbound path exists.** **no inbound path exists** — unless you punch one with `box expose`, and
that door only ever opens onto the host's own loopback (`127.0.0.1`), never
the network.
The VM is the trust boundary: whatever runs inside — Claude, or anything a The VM is the trust boundary: whatever runs inside — Claude, or anything a
template ships — can run arbitrary code and touch nothing you care about. template ships — can run arbitrary code and touch nothing you care about.

111
bin/box
View file

@ -50,6 +50,7 @@ CMDS=(
"down^<box>^box^Stop a box, keeping its state ('start' resumes it)^incus:stop^stopped {}" "down^<box>^box^Stop a box, keeping its state ('start' resumes it)^incus:stop^stopped {}"
"start^<box>^box^Start a stopped box^incus:start^started {}" "start^<box>^box^Start a stopped box^incus:start^started {}"
"rm^<box> [--force]^box,confirm^Delete a box and its snapshots — irreversible, and it asks first^incus:delete -f^removed {}" "rm^<box> [--force]^box,confirm^Delete a box and its snapshots — irreversible, and it asks first^incus:delete -f^removed {}"
"expose^<box> <port> [<host-port>] | --list | --remove <port>^box^Forward a box port to the host's loopback — see a dev server^fn:cmd_expose^"
"incus^<box> -- <args...>^box^Escape hatch: run any incus command against a box^fn:cmd_incus^" "incus^<box> -- <args...>^box^Escape hatch: run any incus command against a box^fn:cmd_incus^"
"doctor^[--fix | --pin-dns]^^Is this host fit to mint boxes? Diagnose the daemon, network, DNS, isolation^fn:cmd_doctor^" "doctor^[--fix | --pin-dns]^^Is this host fit to mint boxes? Diagnose the daemon, network, DNS, isolation^fn:cmd_doctor^"
"status^^^Deprecated alias for 'list'^fn:cmd_status^" "status^^^Deprecated alias for 'list'^fn:cmd_status^"
@ -310,6 +311,28 @@ confirmation first; --force (-f) skips the prompt. With no TTY to confirm on
box rm work box rm work
box rm work --force box rm work --force
EOF EOF
;;
expose) cat <<'EOF'
Open a deliberate, loopback-only door to a port inside a box — for when you
are coding in a box and want to see its dev server in your browser.
box expose <box> <port> [<host-port>] # forward 127.0.0.1:<host-port> → box:<port>
box expose <box> --list # what doors are open
box expose <box> --remove <port> # close one
The host side ALWAYS listens on 127.0.0.1 — no other machine can reach the
box, only this host's loopback. There is no flag to widen that; if you need
LAN exposure you are leaving the tool's threat model, and 'box incus' is the
door (with its warning).
The in-box server must listen on 0.0.0.0:<port>, not only its own loopback —
a VM's forwarder connects to the box over the network. Inside an isolated box
that is safe: nothing but this proxy can reach the port.
box new --name web --template claude
box shell web # inside: run a dev server on 0.0.0.0:3000
box expose web 3000 # then open http://127.0.0.1:3000 in your browser
EOF
;; ;;
incus) cat <<'EOF' incus) cat <<'EOF'
The door out. box wraps the box lifecycle and the isolation model, not The door out. box wraps the box lifecycle and the isolation model, not
@ -399,6 +422,8 @@ while [ $# -gt 0 ]; do
-*) -*)
# doctor's flags belong to the doctor script, not to box # doctor's flags belong to the doctor script, not to box
if [ "$cmd" = doctor ]; then args+=("$1"); shift; continue; fi if [ "$cmd" = doctor ]; then args+=("$1"); shift; continue; fi
# expose's own flags (--list, --remove) are positional to it, not box's
if [ "$cmd" = expose ]; then args+=("$1"); shift; continue; fi
if [ "$cmd" = exec ] || [ "$cmd" = incus ]; then if [ "$cmd" = exec ] || [ "$cmd" = incus ]; then
usage_error "unknown option: $1 — a command's own flags go after --, as in '$(synopsis_of "$cmd")'" usage_error "unknown option: $1 — a command's own flags go after --, as in '$(synopsis_of "$cmd")'"
fi fi
@ -818,6 +843,92 @@ cmd_incus() {
incus "${out[@]}" incus "${out[@]}"
} }
# A deliberate, loopback-only door to a box's port — for the one workflow the
# "no inbound path" contract is too absolute for: you are coding in a box and
# want to open its dev server in your browser.
#
# Two decisions, both load-bearing:
# · The listen side is ALWAYS 127.0.0.1. The network-facing contract stays
# true — no other machine can reach the box; only THIS host's loopback gets
# a door. There is no flag to widen it; that is the escape hatch's job, with
# its warning.
# · Each exposure is a named proxy device (expose-<port>), so 'box info' and
# --list can see it and --remove can undo it. A box with a hole says so.
#
# Mechanism (VMs): an Incus 'proxy' device forwards host 127.0.0.1:<hostport>
# to the box's <ip>:<port>. Because a VM's forkproxy connects over the network
# to the guest, the in-box server must listen on 0.0.0.0 (not just its own
# loopback) — inside an isolated box that is safe: boxnet + port-isolation +
# the ingress drop mean only this proxy can reach it. And that ingress drop is
# exactly what would eat the forkproxy's connection, so expose adds a SCOPED
# ACL allow (this box's ip + this port only) beside the device. Whether the
# proxy needs that allow, or bypasses the ACL, is settled by the drill, not
# here — the allow is scoped and harmless if redundant.
exposure_dev() { echo "expose-$1"; } # device name for a port
cmd_expose() {
local box="${args[0]}" a2="${args[1]:-}" a3="${args[2]:-}"
# --list
if [ "$a2" = "--list" ]; then
local found=0 d listen connect
while IFS= read -r d; do
case "$d" in expose-*) : ;; *) continue ;; esac
listen="$(incus config device get "$inst" "$d" listen 2>/dev/null)"
connect="$(incus config device get "$inst" "$d" connect 2>/dev/null)"
[ "$found" = 0 ] && echo "EXPOSURES for $box"
found=1
printf ' %-14s %s → %s\n' "${d#expose-}" "$listen" "$connect"
done < <(incus config device list "$inst" 2>/dev/null)
[ "$found" = 0 ] && echo "box: $box has no exposed ports"
return 0
fi
# --remove <port>
if [ "$a2" = "--remove" ]; then
local port="$a3"; [ -n "$port" ] || usage_error "usage: box expose $box --remove <port>"
local dev; dev="$(exposure_dev "$port")"
incus config device get "$inst" "$dev" listen >/dev/null 2>&1 \
|| die "$box has no exposure on port $port (see 'box expose $box --list')"
incus config device remove "$inst" "$dev" >/dev/null \
&& echo "box: closed the door on port $port"
# Remove the scoped ACL allow, if one was added. Best-effort: its absence
# is not an error (the drill may show the allow was never needed).
local ip; ip="$(box_ipv4 "$inst")"
[ "$ip" != "-" ] && incus network acl rule remove box-isolate ingress \
action=allow "destination=$ip/32" "destination_port=$port" protocol=tcp >/dev/null 2>&1
return 0
fi
# expose <port> [<host-port>]
local port="$a2" hport="${a3:-$a2}"
[ -n "$port" ] || usage_error "usage: $(synopsis_of expose)"
case "$port$hport" in *[!0-9]*) usage_error "ports must be numbers — got port='$port' host-port='$hport'" ;; esac
local ip; ip="$(box_ipv4 "$inst")"
[ "$ip" != "-" ] || die "$box has no IP yet — is it running? (box info $box)"
local dev; dev="$(exposure_dev "$port")"
if incus config device get "$inst" "$dev" listen >/dev/null 2>&1; then
die "$box already exposes port $port (change or remove it: box expose $box --remove $port)"
fi
# The scoped ACL allow FIRST, so the door is open by the time the proxy uses
# it. Scoped to this box's IP and this port — it does not widen any other box.
incus network acl rule add box-isolate ingress action=allow \
"destination=$ip/32" "destination_port=$port" protocol=tcp >/dev/null 2>&1 || true
if incus config device add "$inst" "$dev" proxy \
"listen=tcp:127.0.0.1:$hport" "connect=tcp:$ip:$port" bind=host >/dev/null; then
echo "box: 127.0.0.1:$hport → $box:$port"
echo "box: (the in-box server must listen on 0.0.0.0:$port, not only its own loopback)"
else
incus network acl rule remove box-isolate ingress action=allow \
"destination=$ip/32" "destination_port=$port" protocol=tcp >/dev/null 2>&1
die "could not add the proxy device"
fi
}
# --- dispatch: driven by the table, not by a hand-written case -------------- # --- dispatch: driven by the table, not by a hand-written case --------------
row="$(cmd_row "$cmd")" || unknown_command "$cmd" row="$(cmd_row "$cmd")" || unknown_command "$cmd"

View file

@ -638,6 +638,59 @@ else
aud "A7 inbound host→box: NOT PROBED" aud "A7 inbound host→box: NOT PROBED"
fi fi
# ===========================================================================
phase "E. box expose — a deliberate loopback door (#55)"
# ===========================================================================
# archive is a running claude box (node is installed). Start a DETACHED
# listener on 0.0.0.0 inside it, expose the port, and prove the door works
# from the HOST's loopback. Then prove removing it closes the door, and that a
# NON-exposed port still obeys the ingress drop — the feature must not
# globally weaken A7.
EP=8091; EHP=18091
srv="$(mktemp)"
printf 'require("http").createServer((q,r)=>r.end("box-expose-ok")).listen(%s,"0.0.0.0")\n' "$EP" >"$srv"
if incus file push "$srv" archive/tmp/srv.js >/dev/null 2>&1; then
rm -f "$srv"
# Detached: setsid + all fds redirected so 'incus exec' returns at once and
# nothing holds its stdout (trap 2/3). The listener outlives the exec.
timeout -k 5 20 incus exec archive -- sh -c 'setsid node /tmp/srv.js >/tmp/srv.log 2>&1 </dev/null &' </dev/null
sleep 3
if box expose archive "$EP" "$EHP" >/dev/null 2>&1; then
ok "box expose archive $EP $EHP — the device was added"
box expose archive --list 2>/dev/null | grep -q "$EP" \
&& ok "expose --list shows the open door" || no "expose --list does not show the exposure"
box info archive 2>/dev/null | grep -qi "$EP" \
&& ok "box info surfaces the exposure (a box with a hole says so)" || note "box info does not mention the exposure (nice-to-have)"
# THE test: does the host's loopback reach the box's server?
sleep 2
if curl -sS -m 6 "http://127.0.0.1:$EHP" 2>/dev/null | grep -q box-expose-ok; then
ok "127.0.0.1:$EHP reaches the box's server — the door WORKS"
else
no "127.0.0.1:$EHP does NOT reach the box — the proxy/ACL mechanism needs work (#55)"
inf "srv.log inside the box: $(in_box archive cat /tmp/srv.log 2>/dev/null | tail -2 | tr '\n' ' ')"
fi
# A NON-exposed port must still be dropped — the feature is per-port, not a
# global ingress opening.
nemsg="$(curl -sS -m 5 -o /dev/null "http://$ARCH_IP:9099" 2>&1)"
printf '%s' "$nemsg" | grep -q 'Connection refused' \
&& no "a non-exposed port answered on the box — expose opened ingress too wide" \
|| ok "a non-exposed port is still dropped — expose is per-port, A7 survives"
# Close it, and confirm the door shuts.
box expose archive --remove "$EP" >/dev/null 2>&1 && ok "box expose --remove closed the device" || no "expose --remove failed"
sleep 2
curl -sS -m 5 -o /dev/null "http://127.0.0.1:$EHP" 2>/dev/null \
&& no "the host still reaches the box after --remove — the door did not shut" \
|| ok "after --remove, 127.0.0.1:$EHP is dead — the door shut"
else
no "box expose failed to add the device — tail: $(box expose archive "$EP" "$EHP" 2>&1 | tail -1)"
rm -f "$srv" 2>/dev/null
fi
timeout -k 5 15 incus exec archive -- pkill -f srv.js </dev/null >/dev/null 2>&1
else
rm -f "$srv"
no "could not push the test server into archive — expose phase did not run"
fi
# =========================================================================== # ===========================================================================
phase "D. The isolation contract, stated" phase "D. The isolation contract, stated"
# =========================================================================== # ===========================================================================