Security

Threat model, isolation guarantees, and disclosure policy for infrastructure that runs code you didn't write on behalf of agents that aren't watching it.


Threat model: every guest is malicious root#

Sandal's starting assumption is not "our customers are trustworthy", it's that every guest is malicious root. An agent given a sandbox may run arbitrary, untrusted, possibly adversarially-generated code, with full root inside its own VM, at any time. Nothing about the design depends on a guest behaving. The isolation boundary has to hold even against a guest that is actively trying to break it, because that's the actual workload: agents running code nobody reviewed, at machine speed, unattended.

Everything below follows from that one assumption.

One VMM process, one uid, per tenant#

Each guest's hypervisor process (Firecracker or Cloud Hypervisor) runs as its own dedicated OS user on the host, with no privileges beyond what that single VM needs. There's no shared VMM daemon multiplexing multiple tenants' guests through one process and one set of file descriptors, a compromise of one guest's VMM process doesn't hand an attacker a foothold into any other tenant's process, because there's nothing else reachable from that uid.

This is the same reasoning that pushed the industry away from shared-kernel containers for hostile-multitenant workloads: process isolation plus a shared kernel is one bug away from cross-tenant compromise. A dedicated kernel per guest, run as a dedicated uid, removes the shared attack surface entirely rather than trying to patch it closed.

Dedicated network fencing, default-deny#

Every guest gets its own TAP device and its own network namespace. There is no shared bridge that a guest could ARP-spoof or sniff its way across to reach a neighbor. The default policy on every guest's network path is deny: a fresh sandbox can't reach the host's management plane, can't reach other tenants' guests, and can't reach anything on the internet until the workload's declared egress rules say otherwise.

Outbound access is explicit and scoped per workload, nothing gets a wildcard route to the internet by default.

No public management listeners, outbound relays only#

Host agents, VMM control sockets, and the internal control plane never listen on a publicly reachable address. There is no management port to scan, no admin API exposed to guest-reachable networks, no "internal" service that's actually just firewalled by convention. Guests that need to talk to the control plane do so through outbound relays: the guest (or the host agent on its behalf) initiates the connection outward to a relay, which is the only thing that ever accepts an inbound connection. An attacker who fully compromises a guest and can send arbitrary packets still can't reach a listening management interface, because there isn't one to reach.

Per-workload encryption keys, destroyed on release#

Every workload's generations (see Architecture) are encrypted with a key scoped to that workload alone, not a shared tenant key, not a shared fleet key. When a sandbox is released (destroyed, not paused), its key is destroyed by the host agent as part of teardown. Once the key is gone, every generation encrypted under it, including anything still resident in S3 or in a host's local NVMe cache, is unrecoverable ciphertext. Deleting the key is the deletion; we don't rely on separately walking every copy of the data and scrubbing it, because the data was never usable without the key in the first place.

Forks never inherit credentials#

const child = await sb.fork() copies filesystem and memory state at the generation level, but it never copies the parent's live credentials. A fork boots with no outstanding capability grants of its own, it has to be issued fresh ones. This is deliberate: forking is a common agent pattern (branch an exploration, try N approaches in parallel), and a credential that silently propagated through every fork would turn one grant into an unbounded number of live copies with no way to reason about who's holding what. Every fork starts at zero and earns its access explicitly.

DPoP-bound, short-lived grants#

Access to a sandbox, device, or runner isn't a long-lived API key handed to the workload. Capability grants are:

  • Short-lived, expiring on a timescale matched to the operation, not to a session.
  • DPoP-bound, sender-constrained to the specific instance holding them, so a grant intercepted or logged elsewhere can't be replayed by a different party. A stolen token without the matching private key is inert.
  • Scoped per instance, a grant issued for one sandbox or one simulator instance doesn't carry authority over any other instance, even ones owned by the same account.

This is the same pattern behind the signed, fenced host-agent commands described in the architecture doc, applied to the client-facing side of the API: authority is tied to a specific holder and a specific target, checked at the point of use, not just at issuance.

Compliance status#

SOC2 is in progress. We'll publish the report and control mapping as soon as the audit completes; until then, treat the guarantees on this page as the accurate current state, not a compliance certification.

Responsible disclosure#

If you find a way to break isolation between tenants, escalate privilege on a host, bypass network fencing, forge or replay a signed command, or otherwise get more access than a scoped grant should allow, we want to hear about it before anyone else does.

  • Report to security@sandal.dev.
  • Include what you found, the smallest reproduction you have, and the impact as you understand it.
  • We'll acknowledge receipt, keep you updated as we investigate and fix, and credit you (if you want credit) once a fix is out.
  • Please don't test isolation boundaries against other customers' live workloads, use your own sandboxes, devices, and runners for any exploit development.

We don't have a bug bounty program running yet; if that changes, this page will say so.