OS-level confinement for the BEAM mechanism on macOS (014 T011 – T015, from
005 R9b and 014's re-measurement at
docs/legacy/specify/014-desktop-deployment/spikes/darwin-hardening/baseline.md).
The composition, and why it has this exact shape
sandbox-exec -f <profile> # filesystem + network
/bin/sh -c 'ulimit -t <N>; exec …' # RLIMIT_CPU
taskpolicy -m <M> # memory
<target> <args…>Measured on macOS 26.5 (25F71), the same build 005 R9b was measured on:
hog 300 under -m 150 exits 137, a spinner that does not limit itself
exits 152 under ulimit -t 2, an ordinary crash exits 139, and a
program inside its caps exits 0. Four outcomes, mutually distinguishable
(SC-002, FR-016).
⚠️ taskpolicy must be the IMMEDIATE PARENT of the target. R9b measured
taskpolicy -m 100 sandbox-exec … ./hog 300 allocating 300 MB under a nominal
100 MB cap and exiting 0 — the cap silently lost across the intervening exec,
failing open. Reproduced here while writing this module: inverted, the hog
prints allocated 300 MB OK.
The rule that generalises from that trap is taskpolicy immediately above
the target, not "no shell anywhere" (baseline Finding 3). A shell placed
before taskpolicy that execs keeps the memory cap, because exec
replaces the shell image rather than forking. That is the only reason the CPU
cap is reachable at all: RLIMIT_CPU needs something to call ulimit, and
this is where it can stand without breaking the memory cap.
⚠️ The intervening shell is an injection surface, and it is closed by argv
ExSandbox.Hardening.Linux composes argv directly and never reaches a shell.
This composition does, and /bin/sh -c takes a single string a shell
parses — so a target path or argument interpolated into that string is
command injection through the sandbox's own command line, which no
sandbox-exec profile closes.
Nothing tenant-influenced is ever interpolated. The script is a constant plus
two integers this module formats itself; the target and every argument are
passed after the script, where sh binds them to $0 and $@:
/bin/sh -c 'ulimit -t 2; exec /usr/sbin/taskpolicy -m 150 "$0" "$@"' \
/path/to/target arg1 arg2They are therefore argv positions, not text, and never reach the parser.
ExSandbox.Hardening.DarwinTest proves it by launching a target whose
arguments carry ;, $(…), backticks, |, & and > with side effects
aimed at a directory the profile permits writing, and asserting the bytes
arrive literally and no side effect occurs.
Why the profile starts from (allow default) — and what that costs
(deny default) is not viable and this is measured, not assumed: it kills
even /bin/echo, because dyld cannot start. R9b recorded it; the template
this module renders (docs/legacy/specify/005-sandbox-beam/spikes/macos-isolation/work.sb)
carries the finding in its own first comment.
So the profile is a deny-list over a permissive default, which is weaker
than default-deny confinement: any operation nobody thought to deny is
allowed. That gap is the reason FR-013 exists — to report the isolation
level honestly — and :privilege_separation stays unavailable on Darwin for
exactly this reason (T021). It must not be papered over by describing this
profile as confinement equivalent to bwrap's.
Note that ExSandbox.Hardening.Confinement reaches a stronger, near
default-deny profile for a control-plane process, by enumerating the
runtime's read set. That is a different problem: it confines one known binary
to one known path. Here the target is arbitrary tenant code whose read set is
unknown before it runs, and an incomplete permit list does not confine it —
it kills it before its first instruction, which reads as every breach
assertion passing (R30's measured 134 on every case, control included).
⚠️ RLIMIT_AS, RLIMIT_DATA and RLIMIT_RSS do not work on Darwin
Measured on this host (T001, and R9b before it): setrlimit on all three
fails with EINVAL, from a soft and hard limit of RLIM_INFINITY.
RLIMIT_CPU is the one that sets.
This matters to anyone porting the Linux mechanism: the address-space cap that
would be the obvious memory limit is unavailable, which is why the memory
cap here is taskpolicy -m and not a setrlimit call. A port that reached for
RLIMIT_AS would cap nothing, silently, and every check short of breaching it
would report success.
⚠️ ulimit -t is per-process and inherited, not pooled
Each child gets its own fresh budget, so a target that forks multiplies the
CPU it can consume. This is strictly weaker than a cgroup CPU quota, and it is
a gap FR-013a must report rather than paper over. It is not a reason to
omit the cap (FR-014): a per-process ceiling stops the single runaway loop,
which is the case US2 names.
Refusal, never a spec with the cap missing
Every function that cannot build a requested cap returns
{:error, {:cannot_enforce, capability, detail}}. It never returns a launch
spec with the cap omitted — that is the fail-open shape ExSandbox.Hardening's
docstring forbids, and it is indistinguishable from success at every layer that
does not breach the cap to check.
What is refused here, and why:
:disk_mb— always. Darwin has no per-process disk quota this composition can impose. A spec that quietly dropped it would report a disk cap that does not exist.:memory_mbwhentaskpolicyis absent from the host.:cpu_millicoreswithout a:wall_clock_secondsbudget — seeapply/3for why the two are one number here.- any launch at all without a positive
:wall_clock_seconds— see below.
What this module does NOT enforce, and refuses to launch without
The wall-clock budget. launch_spec/0 describes how to start a process;
nothing in it can kill one later. The caller enforcing :wall_clock_seconds
by killing the OS pid is the supervisor's job, and 014 T009 verifies the
outcome is distinguishable from every exit status — there is none.
⚠️ Not enforcing it is not a licence to launch without one (FR-014b,
SC-006c, T019). An idle process consumes no CPU, so ulimit -t never fires;
it allocates nothing, so taskpolicy -m never fires; it does not crash. No
layer of this composition ends it. So apply/3 returns
{:error, {:cannot_enforce, :time_budget, …}} rather than a spec with no
budget behind it: this is the last point at which the run can be refused
instead of started, and a run with no terminating condition is the one
shortfall FR-014b says must refuse rather than degrade.
Summary
Types
What available?/0 found, per capability this backend constructs.
Functions
Builds the launch specification enforcing limits for command.
True only when every facility this composition needs is present and the composition actually runs.
The composed command alone, in ExSandbox.Hardening.Linux.build_command/2's
return shape.
What this host can construct, per capability.
The directory generated profiles are written to.
Removes the .sb profile this backend generated for a sandbox, and the
workdir it invented alongside it if that workdir is still empty.
Renders the sandbox-exec profile for workdir and home.
Capabilities this backend requires of the host.
Types
@type capability_map() :: %{ process_separation: boolean(), memory_cap: boolean(), cpu_cap: boolean(), filesystem_confinement: boolean() }
What available?/0 found, per capability this backend constructs.
Functions
@spec apply({String.t(), [String.t()]}, ExSandbox.Hardening.limits(), keyword()) :: {:ok, ExSandbox.Hardening.launch_spec()} | {:error, {:cannot_enforce, atom(), String.t()}}
Builds the launch specification enforcing limits for command.
The caller launches this, not the command it asked about — cmd is
sandbox-exec and the requested command is buried four layers down.
Options
:workdir— the one directory the target may write. Created if absent. Defaults to a fresh directory under the system temp dir.:home— the home directory whoseDocumentsand.sshthe profile denies reading. Defaults toSystem.user_home!/0.:env— the environment allowlist, passed through unchanged. Defaults to[]; this module does not filter it, and a caller passing platform secrets has made a mistakeHardening.Linux.build_command/2catches.:cd— working directory. Defaults to:workdir. ⚠️ Not a boundary; the boundary is the profile. It defaults to the workdir because a process whose cwd the profile denies reading cannotgetcwd, and/bin/shthen printsshell-init: error retrieving current directoryon every launch (measured, from a cwd under~/Documents).
The CPU cap is one number derived from two limits
ulimit -t is a ceiling on CPU-seconds consumed, not on the rate of
consumption — Darwin offers this composition no rate cap at all. So the two
are related by the budget the process is allowed to run for:
cpu_seconds = ceil(wall_clock_seconds × cpu_millicores ÷ 1000)At one core (cpu_millicores: 1000) that is the wall-clock budget itself: a
process spinning flat out hits the CPU ceiling exactly when its budget runs
out, and one trying to use two cores hits it in half the time.
⚠️ This derivation is this module's choice, not a measured fact. What was
measured is only that ulimit -t N kills a non-self-limiting spinner at 152.
A :cpu_millicores with no :wall_clock_seconds is therefore refused rather
than defaulted: there is no budget to derive a ceiling from, and a default here
would impose a CPU cap nobody chose while reading as the one they asked for.
@spec available?() :: boolean()
True only when every facility this composition needs is present and the composition actually runs.
⚠️ The launch is attempted, not inferred. Reading :os.type() and checking two
binaries onto PATH would report true on a host where sandbox-exec refuses
the generated profile — and that is the silent-failure mode this whole slice
exists to remove. So this renders a real profile, runs /usr/bin/true through
the full four-layer composition, and requires exit 0. Measured cost: ~13 ms.
⚠️ It establishes that the composition runs, never that a cap holds.
Only breaching a cap establishes that, which is ExSandbox.Conformance's job
and ExSandbox.Hardening.DarwinTest's (012-FR-012a).
@spec build_command( {String.t(), [String.t()]}, ExSandbox.Hardening.limits(), keyword() ) :: {:ok, {String.t(), [String.t()]}} | {:error, {:cannot_enforce, atom(), String.t()}}
The composed command alone, in ExSandbox.Hardening.Linux.build_command/2's
return shape.
Public for the same reason compose_for_inspection/2 is over there: the
ordering regression test (014 T017) has to assert on the argv this backend
emits, and re-deriving it in the test would let the test and the module drift
apart in the one place where drift is the defect being guarded against.
@spec capabilities() :: capability_map()
What this host can construct, per capability.
Deliberately narrower than ExSandbox.Hardening.Linux.capabilities/0:
:network_restriction and :disk_quota are absent because this backend does
not claim them. (deny network*) is in the profile and denies egress, but the
allowlisted-egress construction :network_restriction names on Linux has no
counterpart here, and :disk_quota is refused outright by apply/3.
Its relationship to ExSandbox.Capability.check/1 (014 T020, T023)
Capability's Darwin clauses for :memory_cap, :cpu_cap and
:process_separation are derived from this map, so the two cannot answer
differently about the same host. ExSandbox.CapabilityTest's Darwin agreement
guard asserts that, and it is written to fail if the derivation is ever
replaced by a second probe.
⚠️ One name diverges on purpose. This map reports :filesystem_confinement
true — the profile really does confine where the target may write, and
ExSandbox.Hardening.DarwinCapabilityTest verifies it by breaching it — while
Capability.check(:filesystem_confinement) reports false on Darwin. They are
answering different questions: that name is in Capability.gating_defaults/0
and in Mechanism.Beam.required_capabilities/0, where it means the mount
namespace, and the BEAM mechanism composes bwrap, which does not exist
here. Flipping it there would admit a sandbox the launch cannot build.
That asymmetry is why T020 flipped three names and not four: the three are report-only, so evidence changes what is said without changing what is admitted.
⚠️ Not free: this runs the composition (~13 ms measured), so every caller
pays a process launch. Capability.check_all/0 on Darwin pays it once per
derived name.
@spec profile_dir() :: String.t()
The directory generated profiles are written to.
Public because release/1's refusal to unlink anything outside it is a safety
property a test has to be able to name.
Removes the .sb profile this backend generated for a sandbox, and the
workdir it invented alongside it if that workdir is still empty.
Accepts the launch spec apply/3 returned, or the profile path directly —
the two carry the same information, because the profile and its workdir are
named from one shared suffix.
Idempotent, for the reason destroy/1 is: a release that raises on an already
released handle turns every crash-and-retry into a stuck sandbox, and the
caller has no way to ask whether it already ran.
⚠️ It unlinks only a .sb file inside this module's own profile directory,
and removes a workdir only when that directory is inside the same place and
empty. The path arrives inside a launch spec that a caller may have built,
edited, or read from configuration, and File.rm_rf/1 on whatever it names
would make this function an arbitrary-delete primitive reachable from the
launch path — one that would take the tenant's output with it.
Renders the sandbox-exec profile for workdir and home.
Public so a test can read the profile this backend would generate without launching anything, and so the substitution can be asserted on directly.
@spec required_capabilities() :: [atom()]
Capabilities this backend requires of the host.
:disk_quota is deliberately absent: it is not required, it is refused.
Requiring it would make every macOS host report this backend unavailable for a
limit most callers never set, while a caller who does set it would get a
vague "unavailable" instead of the specific {:cannot_enforce, :disk_quota, …}
that names what is actually missing.