ExSandbox.Mechanism.Beam.Exec (ExSandbox v1.0.1)

Copy Markdown View Source

Builds the expression that runs one command inside a BEAM sandbox, and decodes what comes back (008 T002, T003).

Why this is source text rather than a fun

It is evaluated on the sandbox node, which runs a bare erl with Elixir's stdlib on its code path and nothing of this project. A closure defined here belongs to ExSandbox.Mechanism.Beam and check_funs_loadable/3 correctly refuses it — the same trap ExSandbox.Mechanism.Beam's probe_exprs/3 documents, which shipped wrong once already. So the command runner is Erlang source, parsed here and evaluated there through :erl_eval.exprs/2, using nothing but OTP.

stdout and stderr are separated by a redirect, not by the port

A port has one output channel. stderr_to_stdout merges the two, and without it the child's fd 2 is simply inherited from the sandbox node and vanishes into the node's own stderr — captured nowhere, attributable to nothing. 008 data-model property 2 requires them separate, so:

/bin/sh -c 'exec "$0" "$@" </dev/null 2>>"$EX_SANDBOX_STDERR"' CMD ARG...
  • "$0" "$@" passes the caller's argv through as argv. Nothing is interpolated into shell text, so there is no quoting to get wrong and no command injection to защит against — an argument containing ; or $(…) is one argument.
  • </dev/null is the stdin-EOF rule 015 R14 measured on MuonTrap.Daemon: a child that reads stdin and never gets EOF blocks until something kills it, turning "this command needs no input" into a wall-clock breach.
  • 2>> appends to a file this module creates before the command runs. If the file cannot be created the command is not run at all and the result is {:could_not_run, {:stderr_capture_unavailable, reason}} — never a silently merged or silently discarded stderr.

truncated? is a fact about bytes, not about lines

015 R17 measured MuonTrap's :logger_fun corrupting lines past a 256-byte buffer, which is why the capture mechanism is decided here rather than inherited. This one has no line buffer at all: the port is opened with :binary and no {:line, _} option, so it delivers whatever chunks the OS pipe produces and the runner concatenates them. A 100 KB line arrives as however many chunks the kernel felt like and reassembles byte-identically.

Truncation is applied once, at a byte limit, per stream:

  • stdout and stderr in the result are the first limit bytes of each stream.
  • truncated? is true if either stream produced more than limit bytes, so a caller reading a build log knows the tail is missing.
  • The command still runs to completion after the limit is reached — the runner keeps draining the port, because closing it early would leave a child writing into a broken pipe and change the exit status this seam is supposed to report faithfully.

Summary

Types

What the sandbox-side runner reports back, once decoded.

Functions

The byte limit each captured stream is truncated at.

Turns whatever the sandbox-side runner returned into the seam's result shape.

The default environment a command runs with inside a sandbox.

Types

outcome()

@type outcome() ::
  {:ok,
   %{
     exit_status: integer(),
     stdout: binary(),
     stderr: binary(),
     truncated?: boolean()
   }}
  | {:could_not_run, term()}

What the sandbox-side runner reports back, once decoded.

Functions

capture_limit_bytes()

@spec capture_limit_bytes() :: pos_integer()

The byte limit each captured stream is truncated at.

Configurable because a build log and a linter's verdict want different ceilings, and a limit that cannot be raised becomes a reason to capture nothing.

decode(other)

@spec decode(term()) :: outcome()

Turns whatever the sandbox-side runner returned into the seam's result shape.

Anything unrecognised is :could_not_run, never a synthesised exit status. 008-FR-016/FR-026 rest on that direction: an attempt whose outcome we cannot read has not failed, and reporting it as a failure spends a refinement iteration the run is not allowed to spend.

default_env()

@spec default_env() :: [{String.t(), String.t()}]

The default environment a command runs with inside a sandbox.

⚠️ PATH is named explicitly. The sandbox's environment is built by env -i with an ERTS-only allowlist (005-FR-004), so a child inherits no PATH and every bare command name fails :enoent — which reads exactly like the sandbox refusing the operation. That ambiguity is the one thing this seam exists to remove, so the directories are named rather than hoped for.