---
hip: 1146
title: Sandbox — A Lease on Isolated Compute
author: Hanzo AI
type: Standards Track
category: Infrastructure
capability: sandbox
status: Final
implementation-go: shipped
created: 2026-08-20
requires: HIP-0026, HIP-0106, HIP-0139
---

# HIP-1146: Sandbox — A Lease on Isolated Compute

## Abstract

`/v1/sandbox` is the ONE compute primitive: a sandbox is a gVisor pod that
runs somebody else's code, and every lifetime is the same object — a function
invoke is a sandbox with a seconds-long lease, a code-exec call one with a
session lease, an agentic coding run one with a project volume and a long
lease. Not three subsystems, not three schedulers: one record, one pod spec,
one way in (`apps/sandbox/sandbox.go:1-10`). The implementation is
`hanzoai/cloud` `apps/sandbox` (the manifest and plugin name the capability
`sandbox`; the package keeps the singular noun).

## Motivation

Running submitted code is one problem however it arrives, and the platform
built it three times before it built it once: exec's predecessor shipped an
in-pod HTTP daemon with eleven endpoints, a shared pool-wide API key and a
pod-IP address book, all deleted because Kubernetes already had the channel
(`apps/sandbox/sandbox.go:32-38`). What differs between a function run and a
coding run is `ttlSec` and whether a volume is attached — a class row, not a
subsystem.

## Specification

The key words MUST, MUST NOT and SHOULD are to be interpreted as in RFC 2119.

### The store

One SQLite file per org, `{DataDir}/orgs/{org}/sandbox.db` via `cloud.OrgDB`:
the org's sandbox registry. Isolation is PHYSICAL — a different org is a
different file — with the org column kept as defence in depth
(`apps/sandbox/store.go:57-61`). The pods themselves are cluster state, bound
by the `Bound` namespace+label pair whose constructor refuses `kube-system`,
`default`, `hanzo` and every `kube-*` namespace before a client even exists
(`apps/sandbox/bound.go`).

### Addresses

Nineteen operations under `/v1/sandbox`
(`apps/sandbox/sandbox.go:241-296`). Thirteen are raw routes with prose
declared beside each (`openapi.Describe`): the collection and member CRUD,
`POST /{id}/exec`, `GET|POST /{id}/fs`, and the ticketed terminal and screen
trios — the terminal is a WebSocket PTY and the screen a live display, wires no
typed op can answer. Six ARE typed ops, registered from the same handlers the
internal plane serves (`lease`, `run`, `read`, `write`, `stop`, `end`), so an
agent at the fleet's MCP endpoint can name them; `stop` ends the WORK and `end` ends the
RESOURCE, two verbs because a run that went wrong is one somebody still wants
to look at.

There MUST be exactly one way into a sandbox — the Kubernetes exec subresource;
fs read/list/write are `cat`, `ls` and `tee` over that channel, never a second
one. Nothing runs in this package: no `os/exec`, ever
(`apps/sandbox/sandbox.go:47-50`).

### Addressing and lifetime

A sandbox is addressed by POD NAME through the apiserver, NEVER by IP: a pod
that dies on its own never runs a release path, and a row holding a stale IP is
served by whichever stranger the CNI handed that address to
(`apps/sandbox/sandbox.go:38-46`). Names are minted per sandbox and never
reused. There is no pool: a sandbox is created for a lease and deleted at its
end; the reaper (every minute) ends expired leases and sleeps sandboxes idle
past an hour, and its orphan sweep deletes by name+UID precondition only what
carries the sandbox label inside the bound namespace, aborting whole when any
store is unreadable.

### Tenancy and credentials

The org is `principal.Org`, refused when absent; it selects the per-org file
and every pod's ownership. No caller credential reaches a sandbox from the pod
spec — a spec value is a value in etcd — and the ONE credential a lease is
handed arrives over the exec channel: a short-lived IAM token EXCHANGED
(RFC 8693) for the token the caller presented, acting as the `hanzo-sandbox`
client which deliberately lacks the admin-mint capability, expiring with the
lease and carrying no refresh token (`apps/sandbox/cred.go:1-28`). The pod
carries no ServiceAccount token at all.

### Money

Metered at a price of zero: `plugin/sandbox/main.go` declares
`Price: cloud.Metered` and `sandbox` is in the `meteredApps` standing list
(`spend.go:311`). A lease is gated and debited through the shared
`cloud.ResourceMeter` under kind `sandbox`, but every class fee defaults to 0
(`SANDBOX_FEE_CENTS[_EXEC|_DEV|_DESKTOP]`), deliberately not to the platform's
$1.00 default: shipping the meter must not also ship a price, and turning one
on is a values change (`apps/sandbox/api.go:128-135`). Bursts are bounded independently of price: an
org's live exec-class sandboxes are capped, refused with 429 because the
correct caller response is to wait.

### Events, telemetry, stage, upstream

It publishes nothing to the bus, so a customer's webhooks receive nothing from
it; beyond the request span it emits log lines only. Stage `ga`: the manifest
row (`manifest/apps.go:368`) declares no stage, and absent means `ga`. Upstream: `k8s.io/client-go`
(Apache-2.0) is the imported client; gVisor's `runsc` and the Kata runtimes
(Apache-2.0) are the isolation boundaries the pod spec selects by RuntimeClass,
run by the cluster, embedded by nothing here.

## Rationale

One primitive with a class row, instead of per-product runtimes, is the whole
design: every fact a sandbox must get right — tenant file, pod addressing,
lease, credential ceiling, reaper — is written once and inherited by function
invokes, code exec and coding runs alike, where three subsystems were three
places to get one of them half-right. The closed `classes` table exists because
its predecessor — three tables in two files — let a new class be half-added
silently: missing from the TTL map it was reaped before its caller finished
reading the reply (`apps/sandbox/sandbox.go:92-101`).

## Security Considerations

This capability's job is running hostile code, so the attacker is assumed to be
inside the pod. What the wrong implementation hands them: a platform credential
(closed by the exchange ceiling — the acting client cannot mint for reserved
orgs, so even an operator's lease starts unprivileged); another tenant's pod (a
stale IP in a row — closed by name-only addressing; a recycled name — closed by
UID preconditions on delete); or the cluster itself (a sweep with a hurried
selector once deleted kube-system DaemonSet pods, which is why the
namespace+label bound is a type no call site can construct half of). The
per-org file plus the org column is the read boundary; the missing
ServiceAccount token is what makes the pod's inside worth less than its
outside.

## References

- HIP-0026 — Identity and Access Management
- HIP-0106 — The Hanzo Plugin Contract
- HIP-0139 — Capability
- RFC 8693 — OAuth 2.0 Token Exchange

## Copyright

Released under CC0 1.0 Universal Public Domain Dedication.
