ExSandbox.Hardening.Darwin (ExSandbox v1.0.0)

Copy Markdown View Source

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 arg2

They 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_mbalways. 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_mb when taskpolicy is absent from the host.
  • :cpu_millicores without a :wall_clock_seconds budget — see apply/3 for 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.

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

capability_map()

@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

apply(arg, limits, opts \\ [])

@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 whose Documents and .ssh the profile denies reading. Defaults to System.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 mistake Hardening.Linux.build_command/2 catches.
  • :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 cannot getcwd, and /bin/sh then prints shell-init: error retrieving current directory on 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.

available?()

@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).

build_command(arg, limits, opts \\ [])

@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.

capabilities()

@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.

profile_dir()

@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.

release(path)

@spec release(term()) :: :ok | {:error, term()}

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.

render_profile(workdir, home)

@spec render_profile(String.t(), String.t()) :: String.t()

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.

required_capabilities()

@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.