ExSandbox (ExSandbox v1.0.1)

Copy Markdown View Source

Isolated execution sandboxes, as a library with no host-application concepts.

ex_sandbox is a composition and evidence layer over operating-system facilities, not a new isolation mechanism. Nothing here invents containment: the primitives are the OS's (cgroup v2, namespaces, bubblewrap, sandbox-exec, Job Objects). What this library adds is a uniform behaviour over them, a capability report that is honest about what the host cannot do, and a conformance suite that establishes claims by observing breaches being stopped rather than by confirming a limiter was invoked.

It depends on Elixir/OTP and nothing else — no Ash, no web framework, no host application (FR-001).

Public interface

These modules are public. A breaking change to any of them is a major version (FR-015):

  • ExSandbox — this module; the top-level API
  • ExSandbox.Mechanism — the behaviour every isolation mechanism implements
  • ExSandbox.Sandbox — the struct passed to every mechanism callback
  • ExSandbox.Capability — the host capability report (FR-016)
  • ExSandbox.Hardening — the OS-level enforcement seam
  • ExSandbox.Conformance — the conformance suite, included via use
  • ExSandbox.Proxy — forwards a request to a running sandbox's address
  • ExSandbox.Telemetry — the events both libraries emit, and their metadata
  • ExSandbox.Conformance.{Lifecycle, Isolation, ResourceLimits, Execution, Helpers, Group} — public by consequence: use ExSandbox.Conformance expands into calls on them inside the consumer's own module, so they are part of the compiled surface whether or not anyone intended it

Everything else is private

A module not listed above is private, whether or not it is namespaced Internal (FR-014). The ExSandbox.Internal.* namespace makes the common case obvious from the module name, but the list above is what defines the boundary — a module that merely lacks the Internal prefix has not thereby become public.

There is no compatibility promise for private modules. Calling one from a consuming application is the coupling FR-004 forbids, and a consumer can check for it mechanically rather than by review: this table also ships as priv/boundary.md inside the package, resolvable at runtime through Application.app_dir(:ex_sandbox, "priv/boundary.md"). A consumer's own test reads the installed copy instead of restating it, so the check cannot drift from the list it is checking against.

What a consumer must supply

Consumer suppliesWhy this library cannot
owner_refIt has no owner concept (FR-007)
Run policyIt has no lifecycle concept (FR-008)
context value, or nilIt has no request-scoping type (FR-003)
Mechanism selection and configurationThe host decides what it can run

A consumer supplying nothing beyond a mechanism gets a working sandbox.

Capability honesty

Every entry point below refuses to start a sandbox when a required capability is unavailable, rather than starting it unconfined (FR-016; 005 R9). A mechanism that cannot isolate must say so — reporting less than it does rather than more is the discipline that makes a cross-platform floor mean anything.

Summary

Types

A module implementing ExSandbox.Mechanism.

Why an operation was refused before the mechanism was ever asked.

Functions

Reports on every capability this host provides.

Destroys a sandbox and releases its resources.

Runs {cmd, args} inside a running sandbox (008-FR-002, 007-FR-041).

Every sandbox the mechanism currently believes is running.

Creates the sandbox's resources without starting it.

Starts a provisioned sandbox.

The sandbox's current state, as the mechanism observes it.

Stops a running sandbox, leaving its resources in place.

Current resource consumption for one sandbox (003-FR-026).

Types

mechanism()

@type mechanism() :: module()

A module implementing ExSandbox.Mechanism.

refusal()

@type refusal() :: {:capability_unavailable, [ExSandbox.Capability.t()]}

Why an operation was refused before the mechanism was ever asked.

{:capability_unavailable, reports} is not an error in the mechanism — it is this library declining to pretend.

Functions

capabilities()

@spec capabilities() :: [ExSandbox.Capability.t()]

Reports on every capability this host provides.

Public so a consumer can decide before provisioning whether this host can run what they need, rather than discovering it from a refusal.

destroy(mechanism, sandbox)

@spec destroy(mechanism(), ExSandbox.Sandbox.t()) :: :ok | {:error, term()}

Destroys a sandbox and releases its resources.

Deliberately not capability-gated. Refusing to clean up because the host cannot isolate would strand resources on exactly the hosts least able to afford them.

execute(mechanism, sandbox, arg, opts \\ [])

@spec execute(
  mechanism(),
  ExSandbox.Sandbox.t(),
  {String.t(), [String.t()]},
  keyword()
) ::
  {:ok, ExSandbox.Mechanism.completion()}
  | {:error, {:could_not_run, term()}}
  | {:error, {:limit_exceeded, :wall_clock | :memory | :cpu}}

Runs {cmd, args} inside a running sandbox (008-FR-002, 007-FR-041).

Deliberately not capability-gated the way provision/2 and start/2 are, and the reason is not laxity: this call reaches into a sandbox that is already running, which means the capability decision was taken at its launch and taken correctly, or there is no sandbox here to reach into. Re-asking now would only add a second answer to a question already settled, and on a host whose report changed mid-flight the second answer would refuse to read the output of work that ran perfectly well under confinement that was real when it started.

The three returns are three different facts — see ExSandbox.Mechanism.execute/3. In particular {:error, {:could_not_run, _}} is not an exit status.

list_running(mechanism)

@spec list_running(mechanism()) :: {:ok, [String.t()]} | {:error, term()}

Every sandbox the mechanism currently believes is running.

Nothing in the happy path calls this; it exists so a host can reconcile recorded state against actual state after a restart (003-FR-015).

provision(mechanism, sandbox)

@spec provision(mechanism(), ExSandbox.Sandbox.t()) ::
  {:ok, ExSandbox.Sandbox.t()} | {:error, refusal() | term()}

Creates the sandbox's resources without starting it.

Refuses when the host lacks a capability the mechanism requires, rather than provisioning something that would run unconfined.

start(mechanism, sandbox)

@spec start(mechanism(), ExSandbox.Sandbox.t()) ::
  {:ok, ExSandbox.Sandbox.t()} | {:error, refusal() | term()}

Starts a provisioned sandbox.

The capability check is repeated here rather than trusted from provision/2: a sandbox may be provisioned on one host and started on another, and a cap that was enforceable at provision time is not thereby enforceable now.

status(mechanism, sandbox)

@spec status(mechanism(), ExSandbox.Sandbox.t()) ::
  {:ok, ExSandbox.Mechanism.status()} | {:error, term()}

The sandbox's current state, as the mechanism observes it.

stop(mechanism, sandbox)

@spec stop(mechanism(), ExSandbox.Sandbox.t()) ::
  {:ok, ExSandbox.Sandbox.t()} | {:error, term()}

Stops a running sandbox, leaving its resources in place.

usage(mechanism, sandbox)

@spec usage(mechanism(), ExSandbox.Sandbox.t()) ::
  {:ok, ExSandbox.Mechanism.usage()} | {:error, term()}

Current resource consumption for one sandbox (003-FR-026).