diff --git a/CHANGELOG.md b/CHANGELOG.md
index 88382e8..eef849a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,9 @@ Versioning after its first public release.
### Added
+- A zero-build, responsive GitHub Pages homepage under `docs/` that presents the
+ measured benchmark, fail-closed boundary, kubeadm quickstart, and architecture
+ without depending on another repository or third-party frontend assets.
- Standalone local gVisor environment, Python SDK, CLI, stdio MCP, and operator Console
surfaces for the first public release candidate.
- `make destroy-local`, a KUBECONFIG-aware Makefile, and resource checks in `make doctor`.
@@ -98,6 +101,9 @@ Versioning after its first public release.
### Fixed
+- The new-profile doctor gate now reflects the default 6 GiB VM and sparse disk's
+ measured physical footprint (6.5 GiB available memory / 25 GiB free disk), so a
+ capable host is not rejected solely by the virtual disk's 60 GiB maximum size.
- Findings of the 2026-09-02 pre-release review, in four groups. Control plane:
request handling, admission and readiness defects found by reading the API
and store paths. Lifecycle: Runtime, workspace and checkpoint state transitions
diff --git a/README.md b/README.md
index 815e54e..5ec12a5 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,8 @@
[](LICENSE)
+[Website](https://hullwork.github.io/sandbox/) · [Documentation](docs/README.md) · [Benchmark report](docs/BENCHMARK_REPORT_2026-09-01.md)
+
**Run an agent's shell and file operations inside a gVisor Pod on your own
Kubernetes cluster.** A Control Plane owns Workspaces, quotas and credentials.
Agents reach it through a Python SDK, the `sandbox` CLI, or a stdio MCP bridge —
@@ -48,7 +50,7 @@ operation fails; it never falls back to running on the host.
```bash
make bootstrap # create .venv and install SDK + test dependencies
-make test # 834 unit and contract tests, no network, no cluster
+make test # 839 unit and contract tests, no network, no cluster
make verify # complete Python, Console, manifest, Helm, wheel gate
make help # every Make target with its one-line description
```
@@ -200,8 +202,8 @@ it first rather than discovering a gap halfway through the VM build.
| Python | 3.11 or newer |
| Host OS | macOS or Linux |
| Host architecture | amd64 or arm64 (`scripts/local-cluster.yaml` pins Ubuntu images for both; gVisor is installed for `x86_64` and `aarch64`) |
-| **Available memory** | **8 GiB free** for a new profile — a hard check, not a warning |
-| **Free disk** | **35 GiB free** under `$LIMA_HOME` (default `~/.lima`) for a new profile — also a hard check |
+| **Available memory** | **6.5 GiB free** for a new profile — the default VM reserves 6 GiB; this is a hard check, not a warning |
+| **Free disk** | **25 GiB free** under `$LIMA_HOME` (default `~/.lima`) for a new profile — the 60 GiB VM disk is sparse; this is also a hard check |
| Virtualization | On Linux, a readable and writable `/dev/kvm`. Without it Lima falls back to QEMU TCG software emulation, which boots kubeadm many times slower and is not usable in practice. `make doctor` warns rather than fails on this one. |
| Network | Egress to pull the Ubuntu cloud image, Kubernetes apt packages, Cilium, gVisor, Metrics Server, and Rook/Ceph images |
diff --git a/docs/.nojekyll b/docs/.nojekyll
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/docs/.nojekyll
@@ -0,0 +1 @@
+
diff --git a/docs/404.html b/docs/404.html
new file mode 100644
index 0000000..db9a0b9
--- /dev/null
+++ b/docs/404.html
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+ Not found · Sandbox Platform
+
+
+
+
+
+
+
+
+
RUNTIME_NOT_FOUND · 404
+
Outside the sandbox.
+
This route does not exist. The good news: nothing fell back to the host.
+
Return to safety →
+
+
+
+
diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md
index 48a8aed..9d3292a 100644
--- a/docs/DEPLOYMENT.md
+++ b/docs/DEPLOYMENT.md
@@ -52,7 +52,7 @@ proof. It records phase timing and outcome in
`.sandbox/quickstart-summary.json`. Use `make up-local` directly when the Python
environment is already prepared and only the deployment needs updating.
-For a new profile, `make doctor` fails when less than 8 GiB of memory or 35 GiB
+For a new profile, `make doctor` fails when less than 6.5 GiB of memory or 25 GiB
of disk is free. When the dedicated `sandbox-local` VM already exists, it uses a
2 GiB memory / 5 GiB disk reuse gate instead. It
warns when `/dev/kvm` is absent on Linux (Lima then falls back to QEMU software
diff --git a/docs/README.md b/docs/README.md
index a6a4d58..fe4c0c9 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -2,6 +2,10 @@
Sandbox Platform provides an execution-as-a-service boundary for agents: a Control Plane, gVisor Runtime, workspace volume services, official Python SDK, and MCP bridge. Start with the [README](../README.md), then use the documents below.
+The project homepage is the zero-build static site in [index.html](index.html).
+GitHub Pages serves this directory from `main:/docs`; assets stay repository-local
+so the public site has no runtime dependency on a separate project or frontend build.
+
| Document | Audience | Use it for |
| --- | --- | --- |
| [README](../README.md) | All users | Product scope, quick start, architecture summary, and current status |
diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md
index bc268a0..f783149 100644
--- a/docs/TROUBLESHOOTING.md
+++ b/docs/TROUBLESHOOTING.md
@@ -12,8 +12,8 @@ export KUBECONFIG="$PWD/.sandbox/kubeconfig"
## `make doctor` cannot reach Docker or reports insufficient capacity
**Symptom.** The first quickstart phase stops before creating a VM because the
-Docker daemon is unreachable, available memory is below 8 GiB, or free space under
-`$LIMA_HOME` is below 35 GiB for a new profile. Reusing an existing `sandbox-local`
+Docker daemon is unreachable, available memory is below 6.5 GiB, or free space under
+`$LIMA_HOME` is below 25 GiB for a new profile. Reusing an existing `sandbox-local`
profile lowers the capacity gate to 2 GiB memory and 5 GiB disk.
**Fix.** On macOS, start Docker Desktop (`open -a Docker`) and wait until it is
diff --git a/docs/assets/sandbox-mark.svg b/docs/assets/sandbox-mark.svg
new file mode 100644
index 0000000..3d261af
--- /dev/null
+++ b/docs/assets/sandbox-mark.svg
@@ -0,0 +1,13 @@
+
+ Sandbox Platform
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/assets/site.css b/docs/assets/site.css
new file mode 100644
index 0000000..64a1981
--- /dev/null
+++ b/docs/assets/site.css
@@ -0,0 +1,429 @@
+:root {
+ color-scheme: dark;
+ --bg: #07090d;
+ --panel: #0d1117;
+ --panel-2: #11181a;
+ --text: #f4f7f4;
+ --muted: #9aa7a3;
+ --line: rgba(183, 219, 205, 0.14);
+ --green: #a7ff83;
+ --mint: #35e6b0;
+ --blue: #67a9ff;
+ --max: 1180px;
+}
+
+* { box-sizing: border-box; }
+
+html {
+ scroll-behavior: smooth;
+ background: var(--bg);
+}
+
+body {
+ margin: 0;
+ overflow-x: hidden;
+ color: var(--text);
+ background:
+ radial-gradient(circle at 78% 5%, rgba(53, 230, 176, 0.12), transparent 27rem),
+ radial-gradient(circle at 8% 34%, rgba(103, 169, 255, 0.06), transparent 24rem),
+ var(--bg);
+ font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ -webkit-font-smoothing: antialiased;
+}
+
+body::before {
+ position: fixed;
+ inset: 0;
+ z-index: -1;
+ content: "";
+ opacity: 0.14;
+ pointer-events: none;
+ background-image:
+ linear-gradient(rgba(255, 255, 255, 0.04) 1px, transparent 1px),
+ linear-gradient(90deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px);
+ background-size: 64px 64px;
+ mask-image: linear-gradient(to bottom, black, transparent 70%);
+}
+
+a { color: inherit; text-decoration: none; }
+
+.container {
+ width: min(calc(100% - 48px), var(--max));
+ margin-inline: auto;
+}
+
+.skip-link {
+ position: fixed;
+ top: 8px;
+ left: 8px;
+ z-index: 100;
+ padding: 10px 14px;
+ color: #07100c;
+ background: var(--green);
+ border-radius: 8px;
+ transform: translateY(-150%);
+}
+
+.skip-link:focus { transform: translateY(0); }
+
+.nav-shell {
+ position: sticky;
+ top: 0;
+ z-index: 20;
+ border-bottom: 1px solid var(--line);
+ background: rgba(7, 9, 13, 0.76);
+ backdrop-filter: blur(20px);
+}
+
+.nav {
+ min-height: 72px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.brand {
+ display: flex;
+ align-items: center;
+ gap: 11px;
+ font-weight: 760;
+ letter-spacing: -0.03em;
+ font-size: 20px;
+}
+
+.brand img { filter: drop-shadow(0 0 13px rgba(167, 255, 131, 0.16)); }
+
+.nav-links {
+ display: flex;
+ align-items: center;
+ gap: 34px;
+ color: #b7c2be;
+ font-size: 14px;
+}
+
+.nav-links a, .github-link { transition: color 160ms ease; }
+.nav-links a:hover, .github-link:hover { color: var(--green); }
+
+.github-link {
+ padding: 9px 14px;
+ border: 1px solid var(--line);
+ border-radius: 999px;
+ font-size: 14px;
+ font-weight: 680;
+}
+
+.hero {
+ min-height: 760px;
+ display: grid;
+ grid-template-columns: 1.08fr 0.92fr;
+ align-items: center;
+ gap: 7%;
+ padding-block: 104px 96px;
+}
+
+.eyebrow, .kicker {
+ color: var(--green);
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+ font-size: 12px;
+ font-weight: 720;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+}
+
+.eyebrow { display: flex; align-items: center; gap: 9px; }
+
+.pulse {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--green);
+ box-shadow: 0 0 0 0 rgba(167, 255, 131, 0.55);
+ animation: pulse 2.2s infinite;
+}
+
+@keyframes pulse {
+ 70% { box-shadow: 0 0 0 8px rgba(167, 255, 131, 0); }
+ 100% { box-shadow: 0 0 0 0 rgba(167, 255, 131, 0); }
+}
+
+h1 {
+ max-width: 760px;
+ margin: 25px 0 25px;
+ font-size: clamp(54px, 6vw, 88px);
+ line-height: 0.98;
+ letter-spacing: -0.07em;
+ font-weight: 760;
+}
+
+h1 em, .closing em {
+ color: transparent;
+ background: linear-gradient(100deg, var(--green), var(--mint));
+ background-clip: text;
+ -webkit-background-clip: text;
+ font-style: normal;
+}
+
+.hero-lede {
+ max-width: 630px;
+ margin: 0;
+ color: #b7c2be;
+ font-size: clamp(18px, 1.7vw, 21px);
+ line-height: 1.65;
+}
+
+.hero-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+ margin-top: 36px;
+}
+
+.button {
+ min-height: 50px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ padding: 0 22px;
+ border: 1px solid transparent;
+ border-radius: 10px;
+ font-size: 15px;
+ font-weight: 740;
+ transition: transform 160ms ease, border-color 160ms ease, background 160ms ease;
+}
+
+.button:hover { transform: translateY(-2px); }
+.button.primary { color: #07100c; background: var(--green); box-shadow: 0 12px 32px rgba(112, 235, 145, 0.12); }
+.button.primary:hover { background: #bdff9f; }
+.button.secondary { color: var(--text); border-color: var(--line); background: rgba(255,255,255,0.025); }
+.button.secondary:hover { border-color: rgba(167,255,131,0.45); }
+
+.trust-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 20px;
+ margin-top: 32px;
+ color: #71807a;
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+}
+
+.trust-row span::before { content: "◆"; margin-right: 7px; color: #43544d; font-size: 7px; vertical-align: 1px; }
+
+.hero-visual { position: relative; min-height: 520px; display: grid; place-items: center; }
+
+.terminal-card {
+ position: relative;
+ z-index: 2;
+ width: min(100%, 520px);
+ overflow: hidden;
+ border: 1px solid rgba(180, 234, 212, 0.18);
+ border-radius: 18px;
+ background: rgba(10, 14, 18, 0.9);
+ box-shadow: 0 30px 100px rgba(0, 0, 0, 0.52), 0 0 0 1px rgba(255,255,255,0.02) inset;
+ transform: perspective(1100px) rotateY(-4deg) rotateX(2deg);
+}
+
+.terminal-bar {
+ height: 49px;
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ padding: 0 17px;
+ border-bottom: 1px solid var(--line);
+ background: rgba(255, 255, 255, 0.018);
+}
+
+.dot { width: 9px; height: 9px; border-radius: 50%; }
+.dot.red { background: #ff6b64; }
+.dot.amber { background: #e7ba55; }
+.dot.green { background: #67d78a; }
+.terminal-title { margin-left: auto; color: #65716d; font: 11px ui-monospace, SFMono-Regular, Menlo, monospace; }
+
+.terminal-body {
+ min-height: 330px;
+ padding: 27px 28px;
+ font: 14px/1.85 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+}
+
+.terminal-body p { margin: 0; }
+.prompt, .ok { color: var(--green); }
+.dim { color: #58645f; }
+.result { margin-top: 16px !important; color: #effff4; font-weight: 650; }
+.cursor { display: inline-block; width: 8px; height: 16px; margin-left: 5px; vertical-align: -3px; background: var(--green); animation: blink 1.1s steps(1) infinite; }
+@keyframes blink { 50% { opacity: 0; } }
+
+.terminal-foot {
+ display: flex;
+ justify-content: space-between;
+ padding: 14px 18px;
+ color: #6f7c77;
+ border-top: 1px solid var(--line);
+ font: 10px ui-monospace, SFMono-Regular, Menlo, monospace;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.healthy { color: #9bd7ae; }
+.healthy i { display: inline-block; width: 6px; height: 6px; margin-right: 5px; border-radius: 50%; background: var(--green); box-shadow: 0 0 8px var(--green); }
+
+.orbital { position: absolute; border: 1px solid rgba(167, 255, 131, 0.12); border-radius: 50%; }
+.orbital::after { position: absolute; width: 8px; height: 8px; content: ""; border-radius: 2px; background: var(--green); box-shadow: 0 0 18px var(--green); }
+.orbital-one { width: 480px; height: 480px; animation: spin 22s linear infinite; }
+.orbital-one::after { left: 60px; top: 55px; }
+.orbital-two { width: 390px; height: 390px; border-style: dashed; animation: spin 17s linear infinite reverse; }
+.orbital-two::after { right: 26px; bottom: 92px; background: var(--blue); box-shadow: 0 0 18px var(--blue); }
+@keyframes spin { to { transform: rotate(360deg); } }
+
+.metrics {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ border-block: 1px solid var(--line);
+}
+
+.metrics article { padding: 32px 25px; border-right: 1px solid var(--line); }
+.metrics article:last-child { border-right: 0; }
+.metrics strong { display: block; font-size: clamp(30px, 3.2vw, 44px); letter-spacing: -0.045em; }
+.metrics strong span { margin-left: 3px; color: var(--green); font-size: 0.48em; font-weight: 650; }
+.metrics p { margin: 7px 0 0; color: var(--muted); font-size: 13px; }
+
+.section { padding-block: 132px; }
+.section-intro { max-width: 750px; }
+.section-intro h2, .quickstart-card h2, .closing h2 {
+ margin: 17px 0 20px;
+ font-size: clamp(39px, 5vw, 64px);
+ line-height: 1.05;
+ letter-spacing: -0.055em;
+}
+.section-intro p, .quickstart-card p { color: var(--muted); font-size: 17px; line-height: 1.75; }
+
+.architecture { margin-top: 72px; }
+.arch-layer { position: relative; border: 1px solid var(--line); border-radius: 18px; background: rgba(13,17,23,0.82); }
+.layer-label { position: absolute; top: -9px; left: 18px; padding: 2px 9px; color: #708078; background: var(--bg); font: 10px ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: 0.1em; }
+.arch-nodes { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; }
+.arch-nodes > div { min-height: 120px; display: flex; flex-direction: column; justify-content: center; align-items: center; background: rgba(255,255,255,0.012); }
+.arch-nodes > div + div { border-left: 1px solid var(--line); }
+.arch-layer b { font-size: 15px; }
+.arch-layer small { display: block; margin-top: 7px; color: #75817d; font: 11px ui-monospace, SFMono-Regular, Menlo, monospace; }
+.arch-link { height: 84px; display: grid; place-items: center; color: #72807a; font: 11px ui-monospace, SFMono-Regular, Menlo, monospace; }
+.arch-link::before, .arch-link::after { position: absolute; content: ""; }
+.arch-link::before { height: 84px; border-left: 1px dashed #3a5148; }
+.arch-link span { position: relative; z-index: 1; padding: 6px 11px; border: 1px solid var(--line); border-radius: 999px; background: var(--bg); }
+.control-layer { padding: 40px 28px 27px; box-shadow: 0 0 70px rgba(53,230,176,0.045) inset; }
+.arch-core { display: grid; grid-template-columns: auto 1fr auto; gap: 18px; align-items: center; }
+.mark-small { width: 46px; height: 46px; }
+.mark-small img { width: 100%; }
+.arch-store { display: flex; gap: 8px; }
+.arch-store span, .runtime-details span, .runtime-badge { padding: 7px 10px; color: #93a59d; border: 1px solid var(--line); border-radius: 7px; background: rgba(255,255,255,0.018); font: 10px ui-monospace, SFMono-Regular, Menlo, monospace; }
+.boundary { position: relative; height: 86px; display: grid; place-items: center; }
+.boundary::before { position: absolute; inset: 42px 0 auto; content: ""; border-top: 1px dashed rgba(255, 107, 100, 0.35); }
+.boundary span { position: relative; z-index: 1; padding: 7px 12px; color: #da8d86; border: 1px solid rgba(255,107,100,0.22); border-radius: 999px; background: var(--bg); font: 10px ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: 0.05em; }
+.runtime-layer { display: grid; grid-template-columns: 1.4fr 0.6fr; gap: 12px; padding: 36px 20px 20px; border-color: rgba(255,107,100,0.18); }
+.runtime-pod, .workspace-node { min-height: 132px; display: flex; flex-direction: column; justify-content: center; padding: 22px; border: 1px solid var(--line); border-radius: 12px; background: rgba(255,255,255,0.015); }
+.runtime-badge { width: max-content; margin-bottom: 15px; color: var(--green); border-color: rgba(167,255,131,0.22); }
+.runtime-details { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 15px; }
+
+.proof-section { border-block: 1px solid var(--line); background: rgba(255,255,255,0.018); }
+.proof-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 14px; margin-top: 62px; }
+.proof-card { position: relative; min-height: 290px; padding: 34px; overflow: hidden; border: 1px solid var(--line); border-radius: 16px; background: var(--panel); }
+.proof-card::after { position: absolute; right: -70px; bottom: -80px; width: 180px; height: 180px; content: ""; border: 1px solid rgba(167,255,131,0.08); border-radius: 50%; }
+.proof-number { color: var(--green); font: 11px ui-monospace, SFMono-Regular, Menlo, monospace; }
+.proof-card h3 { margin: 38px 0 12px; font-size: 24px; letter-spacing: -0.035em; }
+.proof-card p { max-width: 480px; margin: 0; color: var(--muted); line-height: 1.65; }
+.proof-card code { position: absolute; bottom: 29px; left: 34px; color: #6d7b75; font: 11px ui-monospace, SFMono-Regular, Menlo, monospace; }
+
+.benchmark-layout { display: grid; grid-template-columns: 0.8fr 1.2fr; gap: 9%; align-items: center; }
+.text-link { display: inline-flex; gap: 9px; margin-top: 20px; color: var(--green); font-size: 14px; font-weight: 680; }
+.benchmark-panel { padding: 12px 30px; border: 1px solid var(--line); border-radius: 18px; background: linear-gradient(145deg, rgba(17,24,26,0.9), rgba(10,14,18,0.95)); }
+.benchmark-row { display: grid; grid-template-columns: 1.15fr 0.85fr auto; gap: 20px; align-items: center; padding: 26px 0; border-bottom: 1px solid var(--line); }
+.benchmark-row b { font-size: 14px; }
+.benchmark-row small { display: block; margin-top: 6px; color: #6e7a75; font: 10px ui-monospace, SFMono-Regular, Menlo, monospace; }
+.benchmark-row > strong { color: var(--green); font: 14px ui-monospace, SFMono-Regular, Menlo, monospace; white-space: nowrap; }
+.benchmark-row > strong small { display: inline; }
+.bar-track { height: 3px; overflow: hidden; background: #1d2924; border-radius: 3px; }
+.bar-track i { display: block; width: var(--bar); height: 100%; background: linear-gradient(90deg, var(--mint), var(--green)); box-shadow: 0 0 8px var(--mint); }
+.benchmark-note { margin: 20px 0 10px; color: #66736d; font-size: 11px; line-height: 1.6; }
+
+.quickstart-card {
+ display: grid;
+ grid-template-columns: 0.9fr 1.1fr;
+ gap: 8%;
+ padding: clamp(34px, 6vw, 72px);
+ border: 1px solid rgba(167,255,131,0.18);
+ border-radius: 24px;
+ background: radial-gradient(circle at 85% 0, rgba(167,255,131,0.11), transparent 48%), var(--panel-2);
+ box-shadow: 0 40px 100px rgba(0,0,0,0.25);
+}
+.quickstart-card h2 { font-size: clamp(36px, 4vw, 52px); }
+.command-block { align-self: center; display: flex; align-items: center; gap: 10px; padding: 11px 11px 11px 21px; border: 1px solid var(--line); border-radius: 12px; background: #080c0e; }
+.command-block code { flex: 1; color: #eaf5ee; font: 14px ui-monospace, SFMono-Regular, Menlo, monospace; }
+.command-block code span { margin-right: 8px; color: var(--green); }
+.command-block button { padding: 10px 14px; color: #a9b8b1; border: 1px solid var(--line); border-radius: 7px; background: #151d1c; cursor: pointer; }
+.command-block button:hover { color: var(--green); border-color: rgba(167,255,131,0.35); }
+.quick-links { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(3,1fr); gap: 1px; margin-top: 15px; border: 1px solid var(--line); border-radius: 12px; overflow: hidden; background: var(--line); }
+.quick-links a { display: flex; justify-content: space-between; padding: 18px; background: rgba(8,12,14,0.96); font-size: 13px; transition: color 160ms ease, background 160ms ease; }
+.quick-links a:hover { color: var(--green); background: #111b18; }
+
+.closing { padding: 150px 0; text-align: center; border-top: 1px solid var(--line); background: radial-gradient(circle at 50% 45%, rgba(53,230,176,0.12), transparent 24rem); }
+.closing img { filter: drop-shadow(0 0 24px rgba(167,255,131,0.18)); }
+.closing h2 { margin-inline: auto; max-width: 850px; }
+.closing .button { margin-top: 17px; }
+
+footer { border-top: 1px solid var(--line); color: #65716c; font: 10px ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: 0.06em; text-transform: uppercase; }
+.footer-inner { min-height: 84px; display: flex; justify-content: space-between; align-items: center; gap: 20px; }
+
+.reveal { opacity: 0; transform: translateY(18px); transition: opacity 700ms ease, transform 700ms ease; }
+.reveal.visible { opacity: 1; transform: translateY(0); }
+
+@media (max-width: 900px) {
+ .hero { grid-template-columns: 1fr; padding-top: 80px; }
+ .hero-visual { min-height: 490px; }
+ .terminal-card { transform: none; }
+ .metrics { grid-template-columns: repeat(2, 1fr); }
+ .metrics article:nth-child(2) { border-right: 0; }
+ .metrics article:nth-child(-n+2) { border-bottom: 1px solid var(--line); }
+ .benchmark-layout, .quickstart-card { grid-template-columns: 1fr; }
+ .benchmark-panel { margin-top: 42px; }
+ .quick-links { grid-template-columns: 1fr; }
+}
+
+@media (max-width: 640px) {
+ .container { width: min(calc(100% - 30px), var(--max)); }
+ .nav { min-height: 64px; }
+ .nav-links { display: none; }
+ .github-link { padding: 8px 12px; }
+ .hero { min-height: auto; padding-block: 72px 55px; }
+ h1 { font-size: clamp(48px, 14vw, 68px); }
+ .hero-lede { font-size: 17px; }
+ .hero-actions .button { width: 100%; }
+ .trust-row { gap: 11px 16px; }
+ .hero-visual { min-height: 420px; }
+ .terminal-body { min-height: 285px; padding: 22px 18px; font-size: 12px; }
+ .orbital-one { width: 380px; height: 380px; }
+ .orbital-two { width: 300px; height: 300px; }
+ .metrics article { padding: 24px 16px; }
+ .section { padding-block: 92px; }
+ .proof-grid { grid-template-columns: 1fr; }
+ .proof-card { min-height: 280px; padding: 28px; }
+ .proof-card code { left: 28px; }
+ .arch-nodes { grid-template-columns: 1fr; }
+ .arch-nodes > div { min-height: 86px; }
+ .arch-nodes > div + div { border-left: 0; border-top: 1px solid var(--line); }
+ .arch-core { grid-template-columns: auto 1fr; }
+ .arch-store { grid-column: 1 / -1; }
+ .runtime-layer { grid-template-columns: 1fr; }
+ .benchmark-panel { padding-inline: 20px; }
+ .benchmark-row { grid-template-columns: 1fr auto; gap: 10px; }
+ .bar-track { grid-column: 1 / -1; grid-row: 2; }
+ .quickstart-card { padding: 30px 22px; }
+ .command-block { padding-left: 14px; }
+ .command-block code { font-size: 12px; }
+ .footer-inner { align-items: flex-start; flex-direction: column; justify-content: center; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ html { scroll-behavior: auto; }
+ *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; }
+}
diff --git a/docs/assets/site.js b/docs/assets/site.js
new file mode 100644
index 0000000..f6a3b8a
--- /dev/null
+++ b/docs/assets/site.js
@@ -0,0 +1,38 @@
+const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+
+const reveal = () => {
+ const nodes = document.querySelectorAll('.reveal');
+ if (reduceMotion || !('IntersectionObserver' in window)) {
+ nodes.forEach((node) => node.classList.add('visible'));
+ return;
+ }
+ const observer = new IntersectionObserver(
+ (entries) => {
+ entries.forEach((entry) => {
+ if (entry.isIntersecting) {
+ entry.target.classList.add('visible');
+ observer.unobserve(entry.target);
+ }
+ });
+ },
+ { threshold: 0.12 }
+ );
+ nodes.forEach((node) => observer.observe(node));
+};
+
+document.querySelectorAll('[data-copy]').forEach((button) => {
+ button.addEventListener('click', async () => {
+ const label = button.textContent;
+ try {
+ await navigator.clipboard.writeText(button.dataset.copy);
+ button.textContent = 'Copied';
+ } catch {
+ button.textContent = 'Select command';
+ }
+ window.setTimeout(() => {
+ button.textContent = label;
+ }, 1800);
+ });
+});
+
+reveal();
diff --git a/docs/index.html b/docs/index.html
new file mode 100644
index 0000000..9cdc385
--- /dev/null
+++ b/docs/index.html
@@ -0,0 +1,239 @@
+
+
+
+
+
+
+
+
+
+
+
+ Sandbox Platform — Secure execution for AI agents
+
+
+
+
+
+ Skip to content
+
+
+
+
+
+
+
Open source · Alpha 0.1.0
+
Agent execution without the leap of faith.
+
+ Run untrusted shell and file operations on your own Kubernetes cluster.
+ Every runtime is a gVisor Pod. Every workspace survives it. Nothing falls back to your host.
+
+
+
+ MIT licensed
+ Self-hosted
+ kubeadm
+ No vendor runtime
+
+
+
+
+
+
+
+
+
+ sandbox — quickstart
+
+
+
$ make quickstart
+
[1/5] checking host prerequisites
+
✓ kubeadm cluster ready
+
✓ gVisor kernel verified
+
✓ workspace survived restart
+
✓ host fallback blocked
+
sandbox-ready
+
+
+
+
+
+
+
+ 2.497s cold start p50
+ 35.72ms warm execution p50
+ 839 contract tests
+ 0 host fallbacks
+
+
+
+
+
Designed for a hostile workload
+
A narrow waist between your agent and everything else.
+
+ One authenticated API crosses the boundary. The control plane owns policy;
+ the runtime gets only the capability it needs.
+
+
+
+
+
+
YOUR PROCESS
+
+
Python SDK typed client
+
sandbox CLI shell native
+
MCP bridge 9 agent tools
+
+
+
HTTPS + tenant API key
+
+
TRUSTED · SANDBOX-SYSTEM
+
+
+
Control Plane admission · quotas · credentials · lifecycle
+
SQL state S3 checkpoints
+
+
+
default-deny NetworkPolicy
+
+
UNTRUSTED · SANDBOX-WORKLOADS
+
+
gVisor RuntimeClass
+
Ephemeral Runtime Pod
+
non-root read-only root no SA token
+
+
Durable Workspace PVC · independent lifecycle
+
+
+
+
+
+
+
+
The security model is executable
+
Claims you can grep. Boundaries you can break-test.
+
+
+
+ 01
+ Fail closed, always
+ If the control plane or runtime disappears, execution stops. There is no local subprocess escape hatch.
+ fallbacks_to_host = false
+
+
+ 02
+ Credential-bound tenancy
+ Tenant identity comes from the API key—not a request field. Cross-tenant access is tested before Kubernetes is called.
+ wrong_tenant → 404
+
+
+ 03
+ Disposable compute, durable files
+ Stop and replace a Runtime without discarding its Workspace. Checkpoints are explicit recovery, not hidden magic.
+ runtime ≠ workspace
+
+
+ 04
+ No privileged shortcuts
+ No Docker socket, no service-account token, non-root processes, and a read-only root filesystem.
+ kernel = gVisor
+
+
+
+
+
+
+
+
+
Measured, not hand-waved
+
Fast enough to stay in the agent loop.
+
+ Apple Silicon reference profile, dedicated Lima VM, five runs and
+ one hundred measured iterations. No warm runtime pool.
+
+
Read the benchmark method →
+
+
+
+
gVisor Runtime cold start new Pod · schedule to ready
+
+
2.497 s p50
+
+
+
Warm execution existing Runtime
+
+
35.72 ms p50
+
+
+
Workspace create durable PVC-backed state
+
+
29.21 ms p50
+
+
Transparent scope: local, single-node, warm images. Not presented as a cloud or node-cold result.
+
+
+
+
+
+
+
+
From clone to proof
+
One command. A real kubeadm cluster.
+
+ The quickstart creates an isolated Lima VM, installs Kubernetes and gVisor,
+ deploys the platform, and exercises the security and persistence contract.
+
+
+
+ $ make quickstart
+ Copy
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pyproject.toml b/pyproject.toml
index 47be1fe..cdf60f6 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -21,7 +21,7 @@ classifiers = [
]
[project.urls]
-Homepage = "https://github.com/hullwork/sandbox"
+Homepage = "https://hullwork.github.io/sandbox/"
Repository = "https://github.com/hullwork/sandbox"
Issues = "https://github.com/hullwork/sandbox/issues"
diff --git a/scripts/dev-doctor.sh b/scripts/dev-doctor.sh
index 018e43a..db8f273 100755
--- a/scripts/dev-doctor.sh
+++ b/scripts/dev-doctor.sh
@@ -57,7 +57,9 @@ if [ "$(uname -s)" = Linux ]; then
fi
# A new default VM reserves 6 GiB of memory and a 60 GiB disk (sparse)
-# (scripts/local-cluster.sh), so a clean install needs substantial headroom.
+# (scripts/local-cluster.sh). The reference clean install consumes under 15 GiB
+# of physical host storage; 6.5/25 keeps a real safety margin without rejecting a
+# host that can run the profile merely because the sparse virtual size is 60 GiB.
# Reusing an existing VM only needs enough headroom to build images and roll
# Pods; applying the new-VM threshold after a successful install made retries
# fail precisely because the VM was already consuming those resources.
@@ -67,8 +69,8 @@ if [ "${SANDBOX_DOCTOR_SKIP_RESOURCES:-0}" = 1 ]; then
else
LIMA_DISK_DIR="${LIMA_HOME:-$HOME/.lima}"
[ -d "$LIMA_DISK_DIR" ] || LIMA_DISK_DIR="$HOME"
- DEFAULT_MIN_MEMORY_GIB=8
- DEFAULT_MIN_DISK_GIB=35
+ DEFAULT_MIN_MEMORY_GIB=6.5
+ DEFAULT_MIN_DISK_GIB=25
RESOURCE_MODE=new-profile
if limactl list --format '{{.Name}}' 2>/dev/null \
| grep -Fx "${SANDBOX_LOCAL_VM:-sandbox-local}" >/dev/null; then
diff --git a/tests/test_documentation.py b/tests/test_documentation.py
index 803ad47..7782205 100644
--- a/tests/test_documentation.py
+++ b/tests/test_documentation.py
@@ -127,6 +127,10 @@ def test_documented_vm_disk_matches_installer_default(self) -> None:
self.assertIn(f"{size} GiB disk by default", readme)
self.assertIn(f"its {size} GiB disk", readme)
self.assertIn(f"a {size} GiB disk", doctor)
+ self.assertIn("DEFAULT_MIN_MEMORY_GIB=6.5", doctor)
+ self.assertIn("DEFAULT_MIN_DISK_GIB=25", doctor)
+ self.assertIn("**6.5 GiB free**", readme)
+ self.assertIn("**25 GiB free**", readme)
def test_mysql_driver_documentation_matches_the_image(self) -> None:
dockerfile = (ROOT / "control_plane/Dockerfile").read_text(encoding="utf-8")
diff --git a/tests/test_homepage.py b/tests/test_homepage.py
new file mode 100644
index 0000000..1a5812e
--- /dev/null
+++ b/tests/test_homepage.py
@@ -0,0 +1,94 @@
+"""Contract checks for the zero-build GitHub Pages homepage."""
+
+from __future__ import annotations
+
+from html.parser import HTMLParser
+import pathlib
+import unittest
+from urllib.parse import urlparse
+
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+SITE = ROOT / "docs"
+
+
+class _PageParser(HTMLParser):
+ def __init__(self) -> None:
+ super().__init__()
+ self.ids: set[str] = set()
+ self.links: list[str] = []
+ self.assets: list[str] = []
+ self.landmarks: set[str] = set()
+ self.images_without_alt: list[str] = []
+
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
+ values = dict(attrs)
+ if values.get("id"):
+ self.ids.add(values["id"] or "")
+ if tag in {"header", "nav", "main", "footer"}:
+ self.landmarks.add(tag)
+ if tag == "a" and values.get("href"):
+ self.links.append(values["href"] or "")
+ if tag in {"link", "script", "img"}:
+ target = values.get("href") or values.get("src")
+ if target:
+ self.assets.append(target)
+ if tag == "img" and "alt" not in values:
+ self.images_without_alt.append(values.get("src") or "")
+
+
+def parse(name: str = "index.html") -> tuple[str, _PageParser]:
+ text = (SITE / name).read_text(encoding="utf-8")
+ parser = _PageParser()
+ parser.feed(text)
+ return text, parser
+
+
+class HomepageTests(unittest.TestCase):
+ def test_homepage_has_navigation_landmarks_and_skip_link(self) -> None:
+ _, page = parse()
+ self.assertEqual({"header", "nav", "main", "footer"}, page.landmarks)
+ self.assertIn("#main", page.links)
+ self.assertEqual([], page.images_without_alt)
+
+ def test_local_assets_exist_and_are_project_path_safe(self) -> None:
+ for document in ("index.html", "404.html"):
+ _, page = parse(document)
+ for target in page.assets:
+ parsed = urlparse(target)
+ self.assertFalse(parsed.scheme, target)
+ self.assertFalse(target.startswith("/"), target)
+ self.assertTrue((SITE / target).resolve().is_file(), target)
+
+ def test_homepage_has_no_third_party_runtime_dependency(self) -> None:
+ text, page = parse()
+ self.assertNotIn("http://", text)
+ for target in page.assets:
+ self.assertFalse(target.startswith("https://"), target)
+
+ def test_project_claims_match_the_documented_benchmark(self) -> None:
+ homepage, _ = parse()
+ report = (SITE / "BENCHMARK_REPORT_2026-09-01.md").read_text(encoding="utf-8")
+ for value in ("2.497", "35.72", "29.21"):
+ self.assertIn(value, homepage)
+ self.assertIn(value, report)
+
+ def test_homepage_is_sandbox_only(self) -> None:
+ homepage, _ = parse()
+ metadata = (ROOT / "pyproject.toml").read_text(encoding="utf-8")
+ # Keep retired product names out of tracked text while still making the
+ # homepage test fail if one is assembled into the generated page.
+ forbidden = (
+ "platform-" + "composition",
+ "mi" + "ni-" + "agent",
+ "mi" + "ni-" + "sites",
+ "hullwork/" + "agent",
+ )
+ for name in forbidden:
+ self.assertNotIn(name, homepage)
+ self.assertIn("https://github.com/hullwork/sandbox", homepage)
+ self.assertIn('Homepage = "https://hullwork.github.io/sandbox/"', metadata)
+
+
+if __name__ == "__main__":
+ unittest.main()