Capability Enforcement
A module’s declared capability set is checked and then erased at compile time (see Capabilities, where capabilities themselves are defined). That verifies your March code. It says nothing about what the process may do once it is running, or what a node accepts when you hot-patch it in production. This page covers the two mechanisms that turn a declared capability set into an enforced one: an OS-level sandbox that confines the compiled binary at startup, and a deploy-time admission gate that governs hot-patched functions. Both turn what you declare into a boundary that the runtime, or the deploying node, actually holds you to.
A related but different question: has a dependency’s declared capability set
changed since you last checked? forge audit
answers that, by diffing source declarations against a recorded baseline.
That’s still reading, not enforcing. This page covers the mechanisms that
make a declared set actually confine what runs.
OS-level enforcement: sandboxing the compiled binary
FFI is the part of this the compiler can never see: an extern C call, a
dlopen, or a raw syscall runs past the point where capability types apply,
no matter how good the checker gets. That’s the IO.Foreign
boundary, and the gap forge audit
is explicit about not closing. But --cap-sandbox isn’t only about FFI. It’s
also a backstop for ordinary March code: a capability-inference bug, or a
dependency that’s quietly wrong about what it touches, gets caught the same
way an opaque C call does. March can close both cases at the OS level,
turning the declared capability set into an actual confinement.
How much assurance do you actually get, and what does it cost you? From least to most:
| Option | What you do | What you get | Caveat |
|---|---|---|---|
| Nothing (the type system alone) | Just write March; needs/Cap(X) are required to reach any IO builtin |
Compile-time proof of what the code can reach, for anything flowing through a signature or a direct body call | Proves nothing about the running binary. extern/FFI C code is invisible past IO.Foreign. A call routed through a stdlib wrapper (File.read rather than file_read) slips past --check too, though march --compile’s capability ceiling still catches it. |
forge cap inspect |
Run it against a compiled binary | An audit of what capabilities the binary appears to need | Read-only. Reports, doesn’t confine. |
--cap-sandbox (below) |
Add the flag at compile time | The binary sandboxes itself at startup, from its own declared/used capabilities | Self-imposed and opt-in: a binary built without it is simply unconfined. Protects against your own bugs and compromised dependencies, not a hostile publisher. |
forge cap run ./binary (below) |
Run through forge instead of directly | Forge installs the sandbox from outside the process, before it starts | Policy still derives from the binary’s own claimed capabilities. An under-reporting binary gets an under-scoped policy. |
forge cap run --allow-only X ./binary |
Run through forge and state the policy yourself | The strongest option: confinement chosen entirely by you, independent of what the binary claims | You have to know what to allow. Doesn’t stop misuse within an allowed capability. |
The rule of thumb: the type system is the foundation everything else sits on. --cap-sandbox is for code you trust, deployed somewhere forge isn’t the launcher. forge cap run, especially --allow-only, is for code you don’t trust, whenever you can be the launcher.
There are two OS-level mechanisms: one imposed on the process from outside, one built into it.
forge cap run, externally imposed (the stronger one)
forge cap run launches a binary under a sandbox that forge installs before the program gets control:
$ forge cap run ./build/myapp # policy from the binary's own claim
$ forge cap run --allow-only IO.Console ./untrusted # policy YOU choose
For a binary you do not trust, pass --allow-only. Deriving the policy from the binary’s own claim only tells you what it admits to, which is worthless against code trying to hide. Where a capability cannot be enforced by the platform’s available primitive, forge cap run reports it as advisory per capability rather than pretending to enforce it. This is the stronger of the two mechanisms, because the launcher chooses the policy, not the code being confined.
--cap-sandbox, self-imposed (defense in depth)
Compiling with --cap-sandbox embeds a deny-default profile, derived from this program’s own declared capabilities, that the binary installs on itself at startup before any user code runs:
$ march --compile --cap-sandbox -o build/myapp app.march
- macOS: a Seatbelt (SBPL) profile via
sandbox_init(). Deny-default, then each declared capability opens a specific hole:IO.FileWriteallows writes (narrowed to the path scopes you declared, otherwise blanket),IO.Networkallows thenetwork*operation class,IO.Processallowsprocess-fork.IO.FileReadis advisory here: dyld must map system libraries before any user code exists, so the baseline allows reads unconditionally, and a scoped read rule would be decorative. - Linux: an unprivileged in-process seccomp-bpf filter (
PR_SET_NO_NEW_PRIVS+PR_SET_SECCOMP). One syscall class is denied per withheld capability: noIO.Networkblockssocket/socketpair, noIO.Processblocksexecve/execveat, noIO.FileWriteblocks the write path. Denied calls returnEPERM.IO.FileReadis not enforced here either, because seccomp filters syscall numbers, not paths; path-scoped reads come fromforge cap run’s mount namespace instead.
Installation fails closed: if the sandbox cannot be installed, the program refuses to run rather than continue unconfined.
--cap-sandbox is opt-in defense-in-depth, not a guarantee against a hostile publisher. Whoever builds the binary chooses whether to compile it in, so a malicious author simply omits it. Its purpose is a binary you built and trust, deployed somewhere forge is not the launcher: under systemd, a supervisor, a container entrypoint. That’s the exact case forge cap run cannot reach. When you control the launcher, prefer forge cap run.
Because both mechanisms confine the whole process, they bound even the code the compiler cannot see: extern C, dlopen, raw syscalls. They are the enforcement counterpart to forge cap inspect. inspect reads what a binary holds; these enforce what it may do.
Two platform asymmetries, confirmed against real running binaries rather than assumed from source:
- On macOS,
IO.Network’snetwork*grant does not gatesocket()creation itself. It only gates the actual network operation:bind()/connect(). A withheldIO.Networkstill lets a program open a socket; it just can’t do anything with it. Linux deniessocket/socketpairoutright. - On macOS,
IO.Process’sprocess-forkgrant gatesfork()only.process-execis unconditionally allowed in the baseline regardless of capability, so a withheldIO.Processstill lets a programexecve()a new one. Linux is the reverse:execve/execveatare denied,fork/clonenever are (the scheduler needs threads). Tracked as an open question, not settled behavior: specs/todos/2026-08-12-cap-sandbox-macos-process-exec-not-gated.md.
OS primitives, capability by capability
The prose above names the operation classes. This is the full map, including capabilities not mentioned above because they’re advisory on every backend. For what each capability means and when to declare it, see the Capability hierarchy on the Capabilities page. Both enforcement mechanisms were verified against real compiled and running binaries. See test/test_cap_sandbox_runtime.ml (--cap-sandbox) and forge/lib/cap_sandbox.ml’s header comment (forge cap run) for exactly how each row was measured.
--cap-sandbox (self-imposed):
| Capability | macOS (Seatbelt) | Linux (seccomp-bpf) |
|---|---|---|
IO.Network |
network*: gates bind/connect, not socket() creation |
denies socket, socketpair outright |
IO.Process |
process-fork: gates fork() only; process-exec always allowed |
denies execve, execveat; fork/clone never gated |
IO.FileWrite |
file-write* (blanket, or subpath-scoped to a declared @[scope]) |
denies write-flagged openat (O_WRONLY/O_RDWR/O_CREAT/O_TRUNC/O_APPEND) plus the unambiguous mutators (unlink*, rename*, mkdir*, rmdir, truncate*, chmod*) |
IO.FileRead |
Advisory. Baseline unconditionally allows file-read*/file-read-metadata (dyld needs it before user code exists) |
Advisory. Seccomp filters syscall numbers, not path arguments |
forge cap run (externally imposed):
| Capability | macOS (sandbox-exec / SBPL) |
Linux (bubblewrap) |
|---|---|---|
IO.FileWrite / IO.FileSystem |
file-write* |
--ro-bind / / (whole tree read-only) unless granted, then full read-write |
IO.Network / IO.NetConnect / .TLS / IO.WebSocket / IO.Database |
network* |
--unshare-net (network namespace) |
IO.NetListen |
Folded into network*. Enforced, no separate bind/listen split |
Advisory. A network namespace isolates rather than refuses: bind() still succeeds, it’s just unreachable |
IO.Process |
process-fork (Enforced overall, but exec of the target itself can’t be denied, the same underlying gap as --cap-sandbox) |
--unshare-pid |
IO.FileRead |
Advisory. dyld must read system libraries before user code runs | Enforced. An allow-list mount namespace (--ro-bind-try on only the loader’s paths and the binary); anything else is absent, not merely forbidden |
IO.Clock, IO.Spawn, IO.Console, IO.Random, IO.Foreign(.Blocking) |
Advisory everywhere, both platforms. Each is indistinguishable from the runtime’s own baseline traffic (clock_gettime, thread creation, stdout/stderr needed to report violations, /dev/urandom read at startup, foreign C code being outside the capability model entirely) |
(same) |
Hot-deploy authorization: node-local admission control
When using forge deploy hot to upgrade a running application, the node gets a second opportunity to enforce capability discipline at deployment time, after signature verification and before the new code loads.
This section covers the node-side policy gate. There is also a client-side monotonicity gate: a deploy that widens a function’s authority beyond the running version aborts unless you pass
--grant-cap. Both gates, with a full worked example (a console-only handler that gainsfile_write, and how each gate responds), are in the Hot Code Reload guide → Capability-safe deploys.
How it works
A hot deploy activates only the functions that changed; each is sent as a separate signed activation message. For each activated function, forge deploy hot embeds that function’s own inferred IO capabilities (the capabilities its own body actually requires) in the message. Admission is checked per activated function, not over the whole artifact. This granularity matters: --hot-reload links the entire standard library, so a whole-artifact capability set would be dominated by the stdlib’s footprint and identical for every app, useless for a policy. Gating on the changed function’s own caps is what makes the policy discriminating. The trust boundary: the base server binary is trusted (the operator built and started it, with a policy), and each hot-patched function is what the gate governs.
The receiving node, for each activated function:
- Recomputes the capability set: normalizes the function’s declared caps and hashes them with BLAKE3, reproducing the digest that was signed during the deploy.
- Tamper-checks: compares its computed digest to the signed value; a mismatch (
ERR cap_tamper) aborts before dlopen. The tamper check is unconditional even when the function declares no capabilities: a genuinely cap-free function has the fixed digestblake3(""), so a stripped capability field on a signed message is detected rather than silently admitted. - Applies the deployment policy: if
MARCH_DEPLOY_POLICYis set (a file path), the node verifies that every capability the activated function declares is subsumed by a capability listed in the policy; a capability outside policy (ERR cap_policy <cap>) aborts.
Configuring the policy
Set the MARCH_DEPLOY_POLICY environment variable to a file path:
export MARCH_DEPLOY_POLICY=/etc/march/deploy-policy.txt
The policy file is line-delimited. Each non-empty, non-comment line is a permitted capability path:
# /etc/march/deploy-policy.txt
IO
IO.FileRead
IO.NetConnect.TLS
IO.Clock
An empty policy file or absent MARCH_DEPLOY_POLICY means permissive: all activations are admitted. This is the default for backward compatibility. A policy constrains what hot-patched functions may do; it does not retroactively constrain the trusted base binary the operator already deployed.
Threat model and scope
The policy is authorization on a self-reported manifest: a defense-in-depth layer, not a sandbox. A party with the signing key can lie about what capabilities the code uses. The node admission gate proves:
- The artifact was signed by the expected entity (Phase 4 ed25519 signature).
- The declared capability set has not been tampered with in transit (BLAKE3 tamper-check).
- The declared capabilities are within a static policy envelope (subsumption check).
It does not prove that the code actually uses only those capabilities, only that the manifest claims it does, and the claim is signed and untampered. Runtime enforcement via cap no_panic, cap no_alloc, FFI sandboxing, or OS-level confinement can provide stronger guarantees. For most deployments, the combination of compile-time capability verification, signed manifests, and policy gates is sufficient.
See also
- Capabilities: where capabilities are defined and declared. This page enforces what that page declares.
- Capability Audit:
forge auditreads declared capabilities from source and flags when a dependency’s capabilities change;forge cap inspectreads what a compiled binary actually holds. Both read. The mechanisms here enforce. - Safety by Construction: how capabilities sit alongside the other safety axes.