# Sandbox Model A **sandbox** is the execution environment an agent uses to run shell commands and host its workspace files. Each agent has zero or more sandboxes, each persisted as a row in the `sandboxes` table; the runtime constructs an `ExecBackend` from each row when the agent starts. Four backend types are supported: - `docker` — runs commands inside a per-agent Docker container managed by the gateway. - `e2b` — runs commands inside an [E2B](https://e2b.dev) cloud sandbox. - `daytona` — runs commands inside a [Daytona](https://www.daytona.io) cloud sandbox. - `tenki` — runs commands inside a [Tenki](https://tenki.cloud) cloud sandbox. ## Sandbox Row Each sandbox row carries: | Field | Description | |---|---| | `id` | UUID | | `agent_id` | Owning agent | | `alias` | Per-agent unique name. The default alias is `default`. | | `type` | `docker` / `e2b` / `daytona` / `tenki` | | `config` | Type-specific config blob (see below) | | `status` | Lifecycle state: `pending` → `provisioned` → `deleted` (soft delete) | | `external_id` | Backend-assigned identifier (container name, e2b sandbox id, etc.) | | `runtime_state` | Backend-private JSONB used to reconnect after a gateway restart (e.g. queued skill syncs) | | `last_seen_at` | Last successful `ensure()` | A row starts at `pending` and flips to `provisioned` the first time the backend's `ensure()` succeeds. The `runtime_state` lets backends like `e2b` and `daytona` reconnect to a long-lived remote sandbox across gateway restarts. Live runtime state (running vs stopped vs paused) is intentionally **not** stored — the backend itself is the source of truth. `alias` is unique per agent under a partial index that excludes `deleted` rows, so the same alias can be re-created after a soft delete. ## Backend Configs > **Note:** The `host` backend (run commands directly on the gateway machine, as > the gateway process user, with no isolation) has been **removed**. It granted > unsandboxed shell access to the production host and was abused to read gateway > secrets. Agents must use an isolated backend (`docker` / `e2b` / `daytona` / > `tenki`); there is no implicit fallback — an agent with no configured sandbox > has no exec environment. ### `docker` ```json { "type": "docker", "config": { "image": "ubuntu:24.04", "username": "root", "agent_home": "/root" } } ``` `username` and `agent_home` default to `root` / `/root` for ubuntu images. The agent's workspace dir is mounted at `agent_home`. `external_id` records the container name. Optional `memory_limit`, `cpu_shares`, and a per-backend `lifecycle` block override the agent-level lifecycle. ### `e2b` ```json { "type": "e2b", "config": { "template": "base", "username": "user", "agent_home": "/home/user", "sandbox_timeout_ms": 3600000 } } ``` Requires `E2B_API_KEY` in the gateway environment. `template` is the E2B template id. `external_id` records the e2b sandbox id; `runtime_state` queues skill-sync operations so they replay deterministically when the sandbox is reconnected after a gateway restart. ### `daytona` ```json { "type": "daytona", "config": { "image": "ubuntu:24.04", "username": "daytona", "agent_home": "/home/daytona" } } ``` Requires `DAYTONA_API_KEY`. Pass `snapshot` (snapshot id) or `image` (mutually exclusive). Daytona auto-stops idle sandboxes after `auto_stop_interval_minutes` (Daytona default 15) and auto-archives after 7 days; `ensure()` calls `start()` which transparently recovers archived sandboxes — no explicit recover step is needed. ### `tenki` ```json { "type": "tenki", "config": { "timeout_ms": 300000 } } ``` Requires `TENKI_API_KEY` (or `TENKI_AUTH_TOKEN`). The backend uses Tenki SDK 1.4 or newer and works in `/home/tenki` by default. Set `workspace_id` when selecting a workspace explicitly. `timeout_ms` is the guest-enforced per-command budget, defaulting to five minutes. Timed-out commands return exit code 137 and a timeout diagnostic while preserving partial stdout and stderr. A stalled transport is aborted after an additional five-second grace period; when no result arrives, partial output is unavailable. `shutdown()` pauses the sandbox; `ensure()` reconnects and resumes it using persisted runtime state. ## Sandbox Presets Presets live in `gateway.json` and let operators define a small registry of named sandbox templates that users can pick from at agent-create time. ```json { "sandboxPresets": { "docker-ubuntu": { "type": "docker", "config": { "image": "ubuntu:24.04", "username": "root", "agent_home": "/root" } }, "e2b-default": { "type": "e2b", "config": { "template": "base" } }, "daytona-default": { "type": "daytona", "config": {} } }, "autoProvisionSandbox": "docker-ubuntu" } ``` `autoProvisionSandbox` references a preset by name (or `null` to disable auto-provisioning). It is the default when an agent is created without an explicit `sandbox` field. `POST /api/agents` accepts a `sandbox` field: - omitted → use `autoProvisionSandbox` - string → use that preset - `null` → skip provisioning entirely (the agent has no sandbox until one is added) The web UI's create-agent dialog renders a dropdown populated from `GET /api/sandbox-presets`. ## Per-Agent Sandbox API | Method | Path | Notes | |---|---|---| | `GET` | `/api/agents/:id/sandboxes` | List active sandboxes for the agent | | `POST` | `/api/agents/:id/sandboxes` | Add a sandbox: `{ type, alias?, config? }`. Defaults `alias: "default"`. | | `DELETE` | `/api/agents/:id/sandboxes/:alias` | Soft-delete (status flips to `deleted`) | CLI mirror: ```bash hermit sandbox list --agent hermit sandbox add --agent --type docker --config '{"image":"ubuntu:24.04"}' hermit sandbox add --agent --type e2b --config '{"template":"base"}' hermit sandbox remove --agent default ``` The admin UI's **Sandboxes** tab lists every sandbox across all agents, with the lifecycle column from the DB and a runtime column that overlays live `docker ps` info for docker rows (or `—` when the container isn't on this host). ## Lifecycle (per-backend behavior) The agent config's `exec.lifecycle` block controls when a backend is brought up and torn down: ```json { "exec": { "lifecycle": { "start": "ondemand", "stop": "idle", "idle_timeout_minutes": 5 } } } ``` Start policy: - `ondemand` — `ensure()` runs the first time a tool call needs the backend - `session` — `ensure()` runs when a session opens Stop policy: - `idle` — `shutdown()` runs after `idle_timeout_minutes` of inactivity - `session` — `shutdown()` runs when the session ends Soft-deleting a sandbox row (via DELETE) does **not** automatically tear down the remote resource for `e2b` / `daytona`; clean those up explicitly through the respective provider if you want to reclaim the cloud sandbox immediately. ## Security Policy The security policy (in the `agents.security_json` column, managed via `PUT /api/agents/:id/security` or `hermit config security`) is a separate concern from the sandbox: the sandbox decides **where** commands run; the security policy decides **who can talk to the agent** and **when commands need approval**. ```json { "access": "private", "autonomy_level": "supervised", "require_approval_for": ["exec"], "access_token": "..." } ``` `access` ∈ `public` / `protected` / `private`: - `public` — any sender can open a session; unknown channel identities auto-claim a `guest` membership - `protected` — sender must present `access_token` (via `/members` self-join) to become a member - `private` — only owner / admin can add members; everyone else gets a 404 on session open The runtime enforces `access` at session-open time: a sender with no membership row on a non-public agent is rejected before any message is processed, regardless of whether they have a global user record from another agent.