# `ExSandbox.Hardening.Darwin`
[🔗](https://github.com/FoundryStack/ex_sandbox/blob/v1.2.0/lib/ex_sandbox/hardening/darwin.ex#L1)

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 `exec`s 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_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_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.

# `capability_map`

```elixir
@type capability_map() :: %{
  process_separation: boolean(),
  memory_cap: boolean(),
  cpu_cap: boolean(),
  filesystem_confinement: boolean()
}
```

What `available?/0` found, per capability this backend constructs.

# `apply`

```elixir
@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?`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

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

---

*Consult [api-reference.md](api-reference.md) for complete listing*
