ExSandbox.Mechanism behaviour (ExSandbox v1.0.1)

Copy Markdown View Source

The behaviour every isolation mechanism implements (012 T019, T020).

A mechanism is the thing that actually runs code in isolation — a BEAM node under OS confinement (005-sandbox-beam), a container (001-lxd), or something a third party writes. ExSandbox dispatches to one; the conformance suite holds all of them to the same bar.

Every callback takes an ExSandbox.Sandbox.t() — a plain struct — rather than 003's SandboxRecord.t(), which was an Ash resource struct (research R3). This is the change that would have been most expensive to defer: it alters the type in every callback.

⚠️ Do not trust a count of callbacks written in prose, here or anywhere else. This moduledoc said "seven" while the file declared eight, and 012/contracts/execution-seam.md re-titled its own heading for the same reason. Count them in the file.

Why more callbacks than the obvious four

The natural first cut of this API is compile / start / stop / proxy. Several of the callbacks here exist for a specific reason rather than for symmetry, and each would be easy to leave out:

  • status003-FR-024 requires distinguishing "starting" from "not running". :absent and :unknown must not collapse into each other: "we know it is gone" and "we cannot tell" lead to different actions.

  • list_runningthe one most easily dropped, because nothing in the happy path calls it. It is what makes post-restart reconciliation possible at all (003-FR-015). Without it a sandbox that crashed while the host was down stays recorded as running indefinitely, and 003-SC-008 — recorded status matches reality within 60 seconds — is unsatisfiable by construction.

  • usage — per-sandbox CPU, memory, and disk, attributable to the owner (003-FR-026, 010 Story 3).

  • executethe seam that lets a caller run a command inside a sandbox that is already running, added by 012/contracts/execution-seam.md (decided 2026-08-20, Option A shape A2). Until it existed this behaviour could start a workload and stop it and never run anything beside it, so 008-FR-002 (verification runs inside a sandbox, never on the platform's runtime) and 007-FR-041 (a run does not get a private isolation mechanism separate from its environment's sandbox) were both unbuildable. The argument that every mechanism can keep this promise honestly is short: a mechanism that cannot run a command inside a sandbox cannot have started an application in one either.

There is no compile callback

Building a tenant's application is per-stack work, owned by 009-stack-adapters and run inside an already-provisioned sandbox (007-FR-041, 013-FR-021). Putting it on the mechanism would require every mechanism to know how to build every stack — precisely the coupling Principle VI exists to prevent. A mechanism provisions a place to run things; what gets built there is not its business.

Optional: declaring required capabilities

A mechanism may export required_capabilities/0 returning a list of ExSandbox.Capability.name(). ExSandbox refuses to provision or start when any of them is unavailable on this host. A mechanism that does not export it is treated as requiring all of them — under-declaring must not be a way to escape the check (FR-012b).

Optional: declaring constructed capabilities

A mechanism may also export constructed_capabilities/0. ⚠️ It is the opposite claim from required_capabilities/0, and confusing the two inverts the gate. One says what this mechanism needs the host to already provide; the other says what this mechanism builds itself, whether or not the host can.

The distinction exists because the host probe answers a narrower question than the gate asks. ExSandbox.Capability reports whether this host can confine a process directly — on darwin every answer is reasoning about a BEAM node under sandbox-exec. A mechanism whose isolation is a container satisfies the same names by a different construction, and before this callback existed there was no argument position in which to say so: the report was a host probe wearing one mechanism's reasoning.

Omitting it reports nothing, so the gate is the host probe alone and every mechanism written before this callback behaves exactly as it did.

Summary

Types

What one completed command produced.

A chunk of output as it is produced, for opts[:on_output].

What a mechanism observes about a sandbox.

Current consumption, for attribution to the owner (003-FR-026).

Callbacks

Capabilities this mechanism constructs for whatever it runs.

Destroys the sandbox and releases its resources.

Runs {cmd, args} inside a running sandbox and returns what it produced.

Every mechanism_ref this mechanism currently believes is running.

Creates the sandbox's resources without starting it.

Capabilities this mechanism requires of the host.

Starts a provisioned sandbox.

The sandbox's current state as this mechanism observes it (003-FR-024).

Stops a running sandbox, leaving its resources intact.

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

Types

completion()

@type completion() :: %{
  exit_status: integer(),
  stdout: binary(),
  stderr: binary(),
  truncated?: boolean()
}

What one completed command produced.

stdout and stderr are separate because a merged stream cannot attribute a failure, and 015 research R17 measured MuonTrap's :logger_fun corrupting lines past a 256-byte buffer — so how the two are captured is a decision a mechanism must make deliberately, not a detail it may inherit.

truncated? is explicit for the same reason: silent truncation of a build log is how a real error disappears from a diagnosis. It is true when either stream was cut, and the bytes returned are that stream's first bytes.

output_chunk()

@type output_chunk() :: {:stdout | :stderr, binary()}

A chunk of output as it is produced, for opts[:on_output].

⚠️ A chunk is a chunk, not a line. Nothing here promises line framing, and a mechanism must not impose one: 015 R17's defect is precisely a line buffer that corrupts what does not fit it. A caller wanting lines assembles them from the byte stream, where a long line is late rather than mangled.

status()

@type status() ::
  :absent
  | :provisioned
  | :starting
  | :running
  | :stopping
  | :stopped
  | :unknown

What a mechanism observes about a sandbox.

:absent ("it is definitely not there") and :unknown ("we could not determine") are deliberately distinct — collapsing them loses the difference between a sandbox that is gone and a mechanism that cannot see (003-FR-024).

usage()

@type usage() :: %{
  optional(:cpu_millicores) => non_neg_integer(),
  optional(:memory_mb) => non_neg_integer(),
  optional(:disk_mb) => non_neg_integer()
}

Current consumption, for attribution to the owner (003-FR-026).

Callbacks

constructed_capabilities()

(optional)
@callback constructed_capabilities() :: [ExSandbox.Capability.name()]

Capabilities this mechanism constructs for whatever it runs.

Optional; a mechanism that omits it constructs nothing as far as the gate is concerned, which is the safe direction and is exactly the pre-existing behaviour.

⚠️ Not the inverse-named twin of required_capabilities/0 — the opposite claim. See the moduledoc. A mechanism that lists a name here is asserting that a sandbox it starts is subject to that confinement even on a host whose own probe reports it unavailable.

⚠️ Nothing in this behaviour verifies the claim. A mechanism that lists a name it does not build has widened its own gate, and the compiler cannot tell. ExSandbox.Conformance is what establishes such a claim, by observing a breach being stopped rather than by confirming a limiter was invoked — the discipline 005 R9b exists to enforce, where a cap was applied, silently lost across an exec, and the process allocated 300 MB under a nominal 100 MB cap and exited 0. Until a mechanism passes conformance, a name here is a promise backed only by that mechanism's own tests.

destroy(t)

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

Destroys the sandbox and releases its resources.

Must be idempotent: a second destroy returns :ok rather than an error (003-FR-013). A cleanup path that errors on "already gone" turns every crash-recovery sweep into a source of spurious failures.

execute(t, tuple, opts)

@callback execute(
  ExSandbox.Sandbox.t(),
  {cmd :: String.t(), args :: [String.t()]},
  opts :: keyword()
) ::
  {:ok, completion()}
  | {:error, {:could_not_run, term()}}
  | {:error, {:limit_exceeded, :wall_clock | :memory | :cpu}}

Runs {cmd, args} inside a running sandbox and returns what it produced.

This is 012/contracts/execution-seam.md Option A at shape A2: the call returns on completion, and opts[:on_output] may supply a one-argument function receiving output_chunk/0 as output is produced. A caller passing no sink gets exactly the run-to-completion shape, so nothing is paid for what is not used, and 008-FR-061 / 007-FR-023 (in-progress visibility) do not need a second breaking change to this behaviour later.

The three returns are three different facts, and collapsing any two breaks

a requirement

  • {:ok, completion} — the command ran. exit_status is the command's own, whatever it is. A non-zero status is a result, not an error.
  • {:error, {:could_not_run, reason}} — the command did not run: the sandbox was gone, the binary was not there, the mechanism could not reach in. ⚠️ This is not an exit status and must never be reported as one. 008-FR-016 and 008-FR-026 both rest on it: an implementation that maps "the sandbox was gone" onto a non-zero exit has converted an unperformed check into a failed one, and a failed check consumes a refinement iteration that FR-026 says it must not.
  • {:error, {:limit_exceeded, capability}} — the command was stopped by a limit the sandbox was launched under.

Limits are the launch's, never this call's

A mechanism must not read a limit here and enforce it around the command. ExSandbox.Hardening's moduledoc records why: 005 R9b measured a cap silently lost across an intervening exec, allocating 300 MB under a nominal 100 MB cap and exiting 0. A limit re-applied at execution time is a limit applied after the process it governs already exists, which is the shape that fails open. The sandbox is launched under the hardening layer's own command construction -- ExSandbox.Hardening.Linux.build_command/2 on Linux, ExSandbox.Hardening.Darwin.apply/3 on macOS -- and what runs inside it inherits that confinement or the mechanism has none.

Options

  • :on_output(t:output_chunk/0 -> any()), optional.
  • :timeout — a wall-clock ceiling for this call, optional. A sandbox that declares its own budget is bounded by that budget regardless.

A mechanism may accept further options; it must not require any.

list_running()

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

Every mechanism_ref this mechanism currently believes is running.

Exists for reconciliation after a restart (003-FR-015). Nothing in the happy path calls it, which is exactly why it is specified rather than left to each mechanism to provide or not.

provision(t)

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

Creates the sandbox's resources without starting it.

Returns the sandbox with mechanism_ref set — the opaque handle by which this mechanism will recognise it later.

required_capabilities()

(optional)
@callback required_capabilities() :: [ExSandbox.Capability.name()]

Capabilities this mechanism requires of the host.

Optional. A mechanism that omits it is treated as requiring every known capability — see the moduledoc.

start(t)

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

Starts a provisioned sandbox.

status(t)

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

The sandbox's current state as this mechanism observes it (003-FR-024).

stop(t)

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

Stops a running sandbox, leaving its resources intact.

usage(t)

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

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