Start here

macOS tutorial

Install Warden on Apple Silicon, run native Seatbelt policies and understand when OCI backends apply.

This tutorial follows one Apple Silicon machine from its first confined run all the way to the optional OCI targets, stopping on what each backend really guarantees.

The quickstart gets a first confined command running in a few minutes. This tutorial goes further: you see how Seatbelt enforces the policy decision, why the proxy frontend needs an exact SBPL rule in the absence of a network namespace, and when Apple Container or Krun become a relevant target.

Prerequisites

To run Warden

WhatRequirementWhen
SystemApple Silicon, macOS 15 or neweralways
ConfinementSeatbelt, already part of macOSalways
OCI targetslibkrun 1.19.4 with libkrunfw 5.5.0 on macOS 15 or newer; Apple Container 1.3.0 or Apple Containerization 0.41.0 on macOS 26optional
Policy toolingmaelys-policy, from the separate maelys-sandbox-policy packageoptional

brew install maelys-dev/tap/maelys-warden gives you everything needed to compile a policy and run a command under it. The policy compiler and Egress, the network enforcement engine, are static libraries linked into the maelys-warden binary: nothing else to install, no daemon to start. Egress still only acts when a policy asks for mediated networking — under the default network: none, no Egress, no relay and no fd 4 are ever created.

maelys-policy is the one thing that does not come with Warden. It is the CLI of the policy product, useful only to inspect compiled MIR; policy.maelys.dev covers its installation.

Which OCI targets you can reach depends on which of those components you installed, and on your macOS version — the Apple ones need macOS 26, libkrun only needs macOS 15.

No Intel Mac support is announced.

To build from source

WhatRequirement
Compilerthe Xcode Command Line Tools
maelys-system0.4.0, on the exact pinned commit, ABI 1 — required
maelys-sandbox-policy0.4.0 — optional, widens the checks
maelys-egress0.9.0 — optional, widens the checks

maelys-system is the one you cannot skip: Warden links every library object against it, and the build refuses a checkout that is not on the pinned commit. Egress is built against the same contract, which is why a single version governs both. None of the three is a runtime dependency of an installed CLI.

Seatbelt is the program-confinement mechanism built into macOS. Warden compiles your policy into an SBPL profile and starts your command under it, so the kernel itself refuses a forbidden read or connection. Warden drives it, and never asks it for authority your policy did not request.

Install Warden

Install a released binary

PLATFORMInstall a released binary

macOS on Apple Silicon

Every method installs the same binaries.

The tap publishes bottles, so this downloads a prebuilt binary.

brew install maelys-dev/tap/maelys-warden
maelys-warden --version

Always verify a downloaded artifact before installing it: see release integrity.

The Apple Container, Containerization and Krun components are packaged separately, installed further down this tutorial and only if you target OCI images.

Install from source

Only if you want to run the repository test suite, or read and patch what you run. It is not required to follow the rest of this tutorial.

PLATFORMBuild and install from source

macOS on Apple Silicon

Every method installs the same binaries.

Homebrew compiles the formula instead of installing the bottle.

brew install --build-from-source maelys-dev/tap/maelys-warden
maelys-warden --version

Confine a command with inline flags

CODE
maelys-warden run --ro --workspace "$PWD" \
  -- /bin/sh -c 'cat README.md'

maelys-warden run --rw --workspace "$PWD" \
  -- /bin/sh -c 'echo ok > build.out'

Nothing in those two lines names a sandbox, yet both runs are confined. Warden compiled the flags into a decision, then selected a backend: the concrete enforcement mechanism supplied by the operating system, chosen per platform. On this host that backend is seatbelt.

Seatbelt is what actually refuses a forbidden read. The CLI relays stdio through Executor pipes, so Seatbelt is never granted general permission over your terminal; it enforces the filesystem rules, the network refusal and the propagation of the policy to every descendant.

How Warden picks a backend

You never name a backend. Warden collects the candidates that can prove they enforce your decision, and selects the strongest of them — never the most convenient one.

For a local command it knows posix and seatbelt on this platform. For an OCI target on Apple Silicon it can additionally reach krun, krun-bubblewrap and apple-containerization.

Two further backends ship and probe correctly, apple-container and apple-container-bubblewrap, but the CLI cannot select them. Since 0.38 every OCI run carries an exact VM and cgroup budget, and the Apple Container service cannot prove those limits. Rather than approximate them, Warden declares those candidates ineligible. They remain reachable through the historical driver API only.

To make the choice visible in a test or a diagnostic, name one explicitly:

CODE
maelys-warden run --ro --backend seatbelt -- /usr/bin/true

An explicit name is a demand, not a hint. If that backend cannot enforce the decision, the run is refused — Seatbelt cannot reproduce the view mappings and tmpfs that Bubblewrap provides, and a plan requiring them is never quietly launched under something weaker.

The receipt a run leaves behind

Every run can leave a file behind that says what was enforced. Ask for one with --receipt, and Warden writes it after it has waited for the workload:

CODE
maelys-warden run --ro --workspace "$PWD" \
  --receipt /tmp/execution.json \
  -- /usr/bin/true

python3 -m json.tool /tmp/execution.json
EXECUTION RECEIPTSeatbelt, no network
{
  "schema": "maelys.execution-receipt/v1",
  "invocationId": "tutorial-macos-001",
  "decisionDigest": "94d14d6aece6e3a0cdf1409d9a92695b8591fe7016ae56ebad221fcd49ba8bc3",
  "planDigest": "cf14f5b4eff7f425b140948c9c1d53f905c73e7ad6504aaa1d3e04490f812535",
  "mediator": "none",
  "backend": {
    "name": "seatbelt",
    "version": "seatbelt-sbpl-v2"
  },
  "capabilities": {
    "required": 31,
    "backend": 24863
  },
  "network": {
    "mode": "none",
    "frontend": "none"
  },
  "startedUnixMs": 1787541910254,
  "forcedStopAttempted": false,
  "completed": true,
  "outcome": {
    "exitCode": 0,
    "termSignal": 0
  }
}

A local target writes the v1 contract by default. Four fields carry most of the meaning.

backend names the mechanism that did the enforcing — here seatbelt, with the version of the profile generator that produced the SBPL.

decisionDigest is the hash of the compiled decision. It identifies what the run was allowed to do, independently of the machine that enforced it.

planDigest is the hash of the plan: that same decision translated into an executable configuration for this precise backend. Two hosts can share a decisionDigest and differ on their planDigest — that is the portable-decision, local-enforcement split.

capabilities is where backend selection becomes visible. Each bit of those integers stands for one guarantee. required is what the plan needs, backend is what the selected mechanism can deliver. Selection is nothing more than keeping the backends whose mask covers every required bit.

Because compilation is deterministic, recompiling the same policy months later reproduces the same decisionDigest. That is what turns this file from a log line into evidence.

The Linux tutorial walks the same contract field by field, and the receipts reference covers the richer v3 and v4 contracts used by OCI targets.

Network: what is delivered

  • network:none: delivered and enforced by the SBPL profile;
  • network:mediated + fd4: delivered for a Maelys-aware workload;
  • network:mediated + proxy: delivered for standard HTTP/SOCKS tools.

Mediated is the opposite of direct: the workload never opens the connection itself. It hands the request to Egress, a separate network enforcement engine that checks the destination against the allowlist, opens the upstream connection on the workload's behalf, counts the bytes and records the result.

In a receipt, two fields describe that pairing. The mediator names the component that made the network decision — Egress — and the frontend says through which channel the decision was delivered to the workload. proxy is for an unmodified tool, which simply honors proxy environment variables; fd4 is for a program that speaks the Maelys protocol itself on file descriptor 4.

The auto value now resolves to proxy on macOS. It has no effect for a none policy; for a mediated policy, libmaelys-warden starts one per-execution Egress and injects its proxy URLs.

A complete fd 4 example:

CODE
make examples
maelys-warden run --ro \
  --net example.com:80 \
  --network-frontend fd4 \
  -- build/release/bin/example-fd4-client example.com 80

One line per connection

The execution receipt describes the run. A run can open many connections, and those are recorded separately: add --network-receipts and Egress appends one JSON object per line as each connection finishes.

CODE
maelys-warden run --ro --workspace "$PWD" \
  --net example.com:443 \
  --invocation-id tutorial-macos-002 \
  --receipt /tmp/execution.json \
  --network-receipts /tmp/connections.jsonl \
  -- curl https://example.com/

You now hold two files describing one run, and --invocation-id is what ties them together: Warden writes that value into the execution receipt, Egress writes it into every connection line. Without it, a tool reading both files would have to guess the pairing from filenames or timestamps. It is a correlation label only — not a secret, and not a grant of any kind.

How the Seatbelt proxy frontend is confined

Under Bubblewrap, the proxy relies on a loopback relay placed inside a private network namespace; the workload cannot reach any other host port. macOS has no such namespace. Delivering the same contract therefore requires an exact SBPL rule that permits only the execution's own Egress loopback port. The adapter:

  1. starts Egress on 127.0.0.1:0 and waits for the effective port;
  2. reserves the same port on ::1 without a listener, because the SBPL localhost token covers both families;
  3. injects the proxy URLs;
  4. passes only 127.0.0.1:PORT to the Seatbelt backend;
  5. stops the workload before Egress and releases the IPv6 reservation.

The SBPL profile permits network-outbound only towards localhost:PORT. A direct destination, a neighbouring loopback port and the inert IPv6 path are all covered by the macOS behavioral test. No relay and no fallback to the ambient network is used.

Writing a policy: JSON and MIR

Inline flags stop scaling once a policy has more than a handful of rules. A policy file holds the same decision in reviewable form:

CODE
maelys-warden run \
  --policy examples/policies/workspace-readonly.json \
  --emit-mir /tmp/reviewed.mir \
  --receipt /tmp/execution.json \
  -- /usr/bin/true

The JSON source is compiled into MIR, in memory, before anything is spawned. MIR is the policy decision in canonical form — what Warden actually decided once the source was read, validated and normalized — and not the input language itself. You never hand-author it; --emit-mir simply keeps those canonical bytes for inspection.

Canonical means the same policy always compiles to the same bytes, on any host and any platform. That is what makes MIR reviewable in a pull request, hashable into the decisionDigest you saw above, and portable between a macOS laptop and a Linux runner that will enforce it through a completely different mechanism.

maelys-policy reads those bytes back. It ships with the policy product, not with Warden, so install it first — see policy.maelys.dev:

CODE
maelys-policy inspect /tmp/reviewed.mir
maelys-policy hash /tmp/reviewed.mir

inspect prints the normalized decision in readable form — the permission entries as Warden resolved them, not as you wrote them. hash prints the same digest the receipt records, which is how a script checks that a policy still compiles to the decision you reviewed:

CODE
test "$(maelys-policy hash /tmp/reviewed.mir)" = "$(python3 -c \
  'import json,sys;print(json.load(open("/tmp/execution.json"))["decisionDigest"])')"

The policy language is a product of its own — typed permission entries, the C builder API, offline compilation. The full authoring reference lives at policy.maelys.dev, and Policy introduces the concepts as Warden uses them.

Apple Container: another target, not an implicit replacement

On macOS 26 Apple Silicon, Warden can also run a native linux/arm64 OCI image, or a translated linux/amd64 one, inside Apple Container 1.3.0. This path is optional and uses a separate Swift driver.

Everything above confined a command that already existed on your Mac. An OCI target is a different shape of run: the program comes from an image, so the enforcement stack gains a virtual machine layer underneath the policy decision.

--image REFERENCE@sha256:DIGEST is the switch between the two. Without it the CLI builds a local target, and only posix, seatbelt and bubblewrap are candidates. With it the run becomes an OCI target, and that is what makes the four optional candidates selectable at all. A local command is never converted into an image for you.

Install the component only if you use OCI targets:

CODE
brew install maelys-dev/tap/maelys-warden-apple-container-driver

Apple Container 1.3.0 must be installed and configured separately. The driver artifact does not require Xcode at runtime. The CLI builds an explicit OCI TrustedRunSpec:

CODE
maelys-warden run \
  --image alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc \
  --platform linux/arm64 \
  --receipt-schema v4 \
  --receipt /tmp/apple-native.json \
  -- /bin/echo hello

To request Rosetta explicitly, use an image that has an amd64 variant together with --platform linux/amd64. Stable Rosetta proof is mandatory; there is no fallback to arm64.

Passing a value into the image

An OCI run starts from an empty environment: --environment inherit is refused for an OCI target, precisely so that your shell cannot leak into a sealed image. Anything the workload needs, you therefore name explicitly with --env, which is repeatable:

CODE
maelys-warden run \
  --image alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc \
  --platform linux/arm64 \
  --env CI \
  --env BUILD_PROFILE=release \
  --receipt-schema v4 \
  --receipt /tmp/apple-native.json \
  -- /bin/sh -c 'echo "$BUILD_PROFILE in CI=$CI"'

The two spellings are not interchangeable. --env CI imports the value of CI from the environment maelys-warden itself was started in, and writes only the name on the command line. --env BUILD_PROFILE=release writes the value there as well — fine for a build profile, wrong for a credential, since any local process can read a command line with ps. The command and process model lays that contrast out in full.

The receipt an OCI run produces

An OCI target stacks several mechanisms, so its receipt uses the v4 contract: --receipt-schema v4 is required for an OCI target and produces v4, while a local target keeps v1. Receipt v3 remains byte-for-byte frozen for older consumers, but it is no longer what a current OCI run writes.

Four groups replace the flat v1 shape. digests gathers the identities of the MIR, plan, RunSpec, artifact, target and evidence — digests.mir is the v1 decisionDigest, and digests.plan its planDigest. target records what was executed and on which platform. selection shows the chosen candidate, the required and effective capability masks, and the candidates that were rejected. layers enumerates the enforcement chain from outermost to innermost.

Since 0.26.0, v4 additionally freezes the selected Krun runtime variant, its feature set, its admitted component versions and its evidence-bound closure, so the receipt names not only which mechanism ran but which exact build of it. The Krun section below shows a complete v4 receipt.

The simple candidate provides the VM, the read-only OCI root, VirtioFS/tmpfs and the UDS/vsock Egress proxy. The composite injects the verified arm64 Warden bundle and adds the recursive READ/WRITE rules; the image does not have to supply Bubblewrap. It does not advertise DENY, PROTECT_CREATE, inner seccomp or guest fd4 networking. The capability matrix compares those guarantees to host Seatbelt and Bubblewrap.

Krun: a libkrun microVM for linux/arm64

On Apple Silicon, the same --image option also reaches the krun and krun-bubblewrap candidates, restricted to digest-pinned linux/arm64 targets. They come from a separate optional package:

CODE
brew tap libkrun/krun
brew trust libkrun/krun
brew install --build-from-source libkrun/krun/libkrunfw
brew install maelys-dev/tap/maelys-warden-libkrun
brew install maelys-dev/tap/maelys-warden-krun-driver

maelys-warden-libkrun is the Warden-owned headless libkrun runtime; the driver package installs the signed host driver, the deterministic OCI→ext4 materializer and the arm64 guest bundle. It also brings maelys-warden image inspect and maelys-warden image import, the only entry path for an image into the local store. See the Krun driver package for the full install contract.

CODE
maelys-warden run --rw --workspace "$PWD" \
  --image ghcr.io/maelys/tool@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \
  --platform linux/arm64 \
  --backend krun-bubblewrap \
  --receipt-schema v4 \
  --receipt /tmp/krun.json \
  -- /usr/bin/tool build

The simple candidate boots the sealed ext4 root read-only with the requested VirtioFS and tmpfs views; the composite adds the guest Warden Bubblewrap and its recursive READ/WRITE rules. There is no amd64 variant: the hypervisor requires the guest ISA to be the host's. For a mediated policy, the guest relay reaches the execution's Egress over AF_VSOCK port 4520, with no network card and no TSI inside the VM. The VMM itself is confined by a deny-default host Seatbelt profile, probed before the candidate is registered.

The v4 receipt of a Krun composite run

Here is a real example, taken verbatim from the repository fixtures, for the krun-bubblewrap candidate on the headless runtime:

CODE
{
  "schema": "maelys.execution-receipt/v4",
  "executor": {
    "abi": 8,
    "capabilityRegistry": "maelys.executor-capabilities/8"
  },
  "invocationId": "krun-bubblewrap-native",
  "digests": {
    "mir": "sha256:1111111111111111111111111111111111111111111111111111111111111111",
    "plan": "sha256:2222222222222222222222222222222222222222222222222222222222222222",
    "runSpec": "sha256:3333333333333333333333333333333333333333333333333333333333333333",
    "artifact": "sha256:4444444444444444444444444444444444444444444444444444444444444444",
    "target": "sha256:5555555555555555555555555555555555555555555555555555555555555555",
    "evidence": "sha256:6666666666666666666666666666666666666666666666666666666666666666",
    "targetEvidence": "sha256:7777777777777777777777777777777777777777777777777777777777777777"
  },
  "target": {
    "kind": "oci",
    "reference": "ghcr.io/maelys/tool",
    "imageDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
    "platform": "linux/arm64",
    "workingDirectory": "/workspace"
  },
  "selection": {
    "candidate": "krun-bubblewrap",
    "capabilities": {
      "required": {
        "mask": "0x000000000000081f",
        "names": [
          "execution",
          "filesystem",
          "network-none",
          "process-confinement",
          "fs-access-rules",
          "process-tree-lifecycle"
        ]
      },
      "effective": {
        "mask": "0x00000000000008df",
        "names": [
          "execution",
          "filesystem",
          "network-none",
          "process-confinement",
          "fs-access-rules",
          "fs-view-mapping",
          "fs-tmpfs",
          "process-tree-lifecycle"
        ]
      }
    },
    "candidates": [
      {
        "name": "krun-bubblewrap",
        "state": "eligible",
        "reason": "eligible",
        "diagnostic": "sealed MWOCI/3 root and trusted Warden guest bundle"
      }
    ]
  },
  "layers": [
    {
      "name": "seatbelt-host",
      "version": "maelys.warden.krun-host-seatbelt/v1",
      "platform": {
        "mode": "native",
        "execution": "darwin/arm64"
      },
      "capabilityContribution": {
        "mask": "0x0000000000000000",
        "names": []
      }
    },
    {
      "name": "krun",
      "version": "krun-bubblewrap-warden-headless-1.19.4-fw-5.5.0-seatbelt1",
      "platform": {
        "mode": "native",
        "execution": "linux/arm64"
      },
      "capabilityContribution": {
        "mask": "0x00000000000008cf",
        "names": [
          "execution",
          "filesystem",
          "network-none",
          "process-confinement",
          "fs-view-mapping",
          "fs-tmpfs",
          "process-tree-lifecycle"
        ]
      },
      "configuration": {
        "vcpus": 2,
        "memoryMiB": 1024,
        "defaults": "maelys.warden.krun/v1",
        "runtime": {
          "variant": "warden-headless",
          "features": ["blk"],
          "components": [
            {"name": "libkrun", "version": "1.19.4"},
            {"name": "libkrunfw", "version": "5.5.0"}
          ],
          "closureEvidence": "sha256:6666666666666666666666666666666666666666666666666666666666666666"
        },
        "helperEvidence": "sha256:6666666666666666666666666666666666666666666666666666666666666666"
      }
    },
    {
      "name": "bubblewrap",
      "version": "warden-static-linux-arm64/evidence-sha256:6666666666666666666666666666666666666666666666666666666666666666",
      "platform": {
        "mode": "native",
        "execution": "linux/arm64"
      },
      "capabilityContribution": {
        "mask": "0x0000000000000010",
        "names": [
          "fs-access-rules"
        ]
      }
    }
  ],
  "network": {
    "mode": "none",
    "frontend": "none",
    "mediator": "none"
  },
  "startedUnixMs": 1787712000000,
  "lifecycle": {
    "forcedStopAttempted": false,
    "domainCompleted": true,
    "outcomeKnown": true
  },
  "outcome": {
    "exitCode": 0,
    "termSignal": 0
  }
}

Read layers from outside in: the host Seatbelt profile that confines the VMM, then the microVM, then the guest Bubblewrap that lowers the recursive filesystem rules. Only the last two contribute capabilities; seatbelt-host contributes none, because it protects the host rather than the workload. Under layers[1].configuration.runtime, v4 pins the exact runtime that ran: warden-headless, the single blk feature, and the admitted libkrun and libkrunfw versions.

The policy asked for network:none, so no Egress was started: mediator and frontend are both none.

Going further: writing your own policies

Every run above starts from a policy, inline through --ro/--rw or explicit through --policy. Here is its minimal shape, reduced to the fields that give it structure:

CODE
{
  "$schema": "https://schemas.maelys.dev/mir-source/v2/schema.json",
  "formatVersion": 2,
  "filesystem": {
    "default": "deny",
    "rules": [
      {"access": "read", "path": {"root": "workspace", "relative": ""}, "scope": "tree"}
    ]
  },
  "network": {"mode": "none"},
  "process": {"treeConfinement": "required"}
}

default:deny refuses everything that is not listed, workspace:"" names the root supplied by the host, and scope:tree extends the rule to its descendants.

For the complete policy-authoring reference — typed permission entries, the full JSON and MIR language, compilation — see policy.maelys.dev. The policy and canonical MIR page describes the Warden side: builders, canonical bytes and digests.