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/nullis the stdin-EOF rule015R14 measured onMuonTrap.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:
stdoutandstderrin the result are the firstlimitbytes of each stream.truncated?istrueif either stream produced more thanlimitbytes, 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
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
Functions
@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.
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.
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.