agentic-vps — provision & harden a box for autonomous agentic development
The verb for "make me another machine like the one we just set up." Distilled from the srv1692698 session (BRO-1550). It does not reimplement server hardening — it encodes the capability-preserving model and the lockout-safe staging sequence so a future agent reaches for it instead of re-deriving it.
The one invariant
The box IS the sandbox. Security comes from containing the blast radius (non-root agent user · VPN-only access · snapshot rollback · no long-lived secrets in the agent's reach), not from restricting the agent (no tight egress allowlist, no permission prompts, no seccomp leashes). The agent gets the same freedom it has on a local machine — full sudo, open network, any dep, YOLO mode — because a compromise is contained and reversible, not prevented.
This is the user's standing constraint: "secure but don't limit capability/autonomy."
The model and its threat-model rationale live in
research/entities/concept/lethal-trifecta-denial.md and the playbook at
docs/security/2026-06-24-vps-agentic-hardening-playbook.html.
Hard rules (the lockout-safety sequence)
These are non-negotiable ordering constraints. scripts/staging_check.py
enforces them deterministically — a plan that violates them fails the gate.
- Snapshot before any risky change. A rollback point must exist first.
- VPN up + verified BEFORE closing public SSH. Bring up the mesh VPN
(Tailscale/WireGuard), then in a second, independent session confirm
ssh agent@<vpn-ip>works — only then remove the public :22 rule. - Allow the new path before removing the old. UFW:
allow in on <vpn-if>beforedelete allow OpenSSH. Edge firewall: add the VPN rule, verify, then drop TCP/22 and sync. - Verify on a fresh connection after every SSH/firewall change — never assume the change is safe from the session that made it.
- Out-of-band recovery must exist (cloud Browser Terminal / recovery mode) before public SSH is closed, since there is no SSH fallback afterward.
Procedure
Phase 0 — pre-flight (latent: per-provider control plane)
- Identify the provider control plane (Hostinger VPS MCP, AWS, etc.).
- Snapshot the box (Hostinger:
VPS_createSnapshotV1). - Confirm out-of-band console access exists.
Phase 1 — provision (deterministic: scripts/provision.sh)
Run as root over SSH — idempotent, safe to re-run:
ssh root@<host> 'AGENT_USER=agent bash -s' < scripts/provision.sh
Creates the non-root agent user (passwordless sudo + docker), installs the
toolchain (Node, Bun, Rust, gh, Docker, Claude Code + sandbox-runtime),
generous resource caps (host-survival, not throttle), a bypassPermissions
Claude config, and git identity. Tailscale is installed but auth is
interactive (prints a login URL).
The config also sets
denyReadon credential dirs, but that only takes effect if you flipsandbox.enabled=true— in the default capability- preserving (sandbox-off) mode it is inert. The primary secrets protection is no long-lived secret in the agent's env (Phase 2: interactive OAuth, scoped tokens), whichverify.py'sno_secrets_in_envgate actually checks.
Phase 2 — credentials (latent: user authenticates interactively)
The user SSHes in and logs into the CLIs themselves — never paste long-lived broad keys into env:
claude→/login(OAuth, stored in~/.claude/.credentials.json)gh auth login && gh auth setup-git(prefer a fine-grained PAT scoped to specific repos over a broadrepoOAuth token)
Phase 3 — perimeter (deterministic order; see Hard rules)
tailscale up --hostname=<name>→ user authorizes the printed URL.- Verify
ssh agent@<tailnet-ip>in a fresh session. - UFW:
allow in on tailscale0+allow 41641/udp, thendelete allow OpenSSH. - Edge firewall: add UDP 41641, remove TCP/22, sync.
Phase 4 — verify (deterministic: scripts/verify.py)
Collect facts from the box and assert the capability-preserving invariants:
python3 scripts/verify.py --host agent@<tailnet-ip> [--public-ip <ip>]
Gates (required): non-root agent user · resource caps set · Tailscale up · public :22 closed · no long-lived secret in agent env. Recommended: snapshot exists · sandbox config present · git identity set.
What's deterministic vs latent
| Deterministic (scripts + tested) | Latent (agent judgment) |
|---|---|
provision.sh — idempotent install steps |
which provider / control plane |
staging_check.py — lockout-safe ordering |
which toolchains this box needs |
verify.py — invariant evaluation |
how the user authenticates (OAuth vs PAT) |
| the 5 hard rules | when to defer Tier-3 monitoring |
Anti-rationalization
| Excuse | Reality |
|---|---|
| "Just close :22, the VPN's up." | Not until ssh agent@<vpn-ip> is verified in a second session. staging_check.py fails the plan otherwise. |
| "Lock the agent down hard." | That violates the one invariant. Contain the blast radius; don't leash the agent. |
"Put the API key in .bashrc so it's always there." |
Long-lived broad secret in the agent's reach = leakable by any injected command. verify.py fails on it. |
| "Skip the snapshot, it's a fresh box." | The snapshot is the only reversibility. Hard rule 1. |
References
research/entities/concept/lethal-trifecta-denial.md— the security modeldocs/security/2026-06-24-vps-agentic-hardening-playbook.html— full rationaledocs/security/srv1692698-runbook.html— operations (access/recover/rebuild)docs/security/srv1692698-provision.sh— the original instance this generalizes