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 states no fact 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 makes binding on you.
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 safety net for ordinary March code: a capability-inference bug, or a
dependency that’s wrong, with no visible sign, 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 |
|---|---|---|---|
| No extra step (only the type system) | 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 no property of 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) is caught by the capability upper limit: march --compile’s check completely, and since 2026-08-17 a sound subset also under --check. |
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 concedes, 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.NetConnectallowsnetwork-outbound(which also covers name resolution:getaddrinforeaches mDNSResponder over a unix socket),IO.NetListenallowsnetwork-bindandnetwork-inbound,IO.Network(orIO) allows both,IO.Processallowsprocess-forkandprocess-exec.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.NetListenblocksbind/listen(so a program holding onlyIO.NetConnectcan connect but not accept connections), 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 will not run rather than continue unconfined.
Path scopes. A filesystem capability can be narrowed to a directory subtree: needs IO.FileWrite("/var/lib/myapp"). A literal path outside every declared scope is a compile error, and --cap-sandbox narrows its write grant to the declared subtrees. Two scopes are compile errors rather than being silently ignored: a scope on a capability that is not IO.FileRead, IO.FileWrite or IO.FileSystem (for example needs IO.Network("/etc"), or needs IO("/srv"); scope the filesystem capability you mean instead), and a relative scope ("etc/myapp", "./out"), which would name a different directory depending on the working directory at run time.
Scopes are normalized lexically at compile time (//, . and .. collapsed; no filesystem access), because the build machine’s filesystem is not the deployment machine’s. Symlinks are resolved at run time instead: the macOS kernel matches a subpath only after resolving symlinks, so the --cap-sandbox binary realpath()s each write scope at startup, just before it installs the profile. You can therefore write the path the way your program spells it: needs IO.FileWrite("/tmp/myapp") enforces against /private/tmp/myapp on macOS, where /tmp is a symlink. A scope that does not exist yet (the usual case when the program creates it) resolves through its longest existing prefix, with the rest appended. A scope that is itself a symlink resolves to its target, as it stands when the program starts. On Linux, --cap-sandbox enforces IO.FileWrite all-or-nothing through seccomp, which cannot see paths, so a write scope is checked statically there but not narrowed at run time.
forge cap run narrows its sandbox to the same scopes. A compiled binary whose filesystem needs carry a scope embeds them in a small manifest, and forge cap run reads it back and resolves each scope on the machine that runs the program. On macOS a write scope becomes a subpath write grant. On Linux (bubblewrap) a write scope becomes a writable bind of that directory, and a read scope becomes a read-only bind inside the allow-list namespace, so a path outside every scope is absent. A scope only narrows a capability the policy already grants, so it applies under --allow-only too and can never widen it. One unscoped declaration of the same capability (including IO.FileSystem or IO) means any path, and then nothing is narrowed. A scope is the program’s own declaration, not something proved from its code, and for enforcement that is enough: the sandbox blocks everything outside it, so a program that understates its scope only breaks itself. Two limits. bubblewrap cannot bind a directory that does not exist yet, so a scope must exist before the program starts under forge cap run on Linux. A read scope cannot be enforced when IO.FileWrite is granted unscoped, because that needs the whole filesystem mounted. forge cap run prints a line for each scope saying whether it is enforced. A binary from an older compiler, or one with no scopes, has no manifest and runs exactly as before.
--cap-sandbox is opt-in defense-in-depth, not a guarantee against a hostile publisher. The party building 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 complement to forge cap inspect. inspect reads what a binary possesses; these enforce what it may do.
Two platform asymmetries, confirmed against real running binaries rather than assumed from source:
- On macOS, the network grants do not gate
socket()creation itself. They only gate the actual network operation:bind()/listen()(network-bind,network-inbound) andconnect()(network-outbound). A withheldIO.Networkstill lets a program open a socket; it just can’t do anything with it. Linux deniessocket/socketpairentirely. - On macOS, a withheld
IO.Processdenies bothprocess-forkandprocess-exec, so a program can neitherfork()norexecve()a new image. Linux deniesexecve/execveatbut neverfork/clone(the scheduler needs threads). (Before 2026-09-21 macOS leftprocess-execin the baseline, so exec was ungated there; the embedded profile is applied from inside the already-running process, so nothing needs that grant. Measured: without it a sandboxed binary starts normally andexecve()returnsEPERM.)
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-outbound + network-bind + network-inbound: gates connect/bind/listen, not socket() creation |
denies socket, socketpair entirely |
IO.NetConnect (and .TLS, IO.WebSocket, IO.Database) |
network-outbound only: DNS, TCP and TLS clients work; bind/listen return EPERM |
allows socket/connect; bind/listen still need IO.NetListen |
IO.NetListen |
network-bind + network-inbound only: a server can listen and accept; an outbound connect returns EPERM |
denies bind, listen (a withheld IO.NetListen with IO.NetConnect held still allows socket/connect) |
IO.Process |
process-fork and process-exec: gates fork() and execve() |
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*, or subpath-scoped to the declared write scopes |
--ro-bind / / (whole tree read-only) unless granted, then full read-write, or writable binds of the declared write scopes only |
IO.Network |
network-outbound + network-bind + network-inbound |
--unshare-net (network namespace) withheld only when no network capability is held |
IO.NetConnect / .TLS / IO.WebSocket / IO.Database |
network-outbound only (DNS, TCP, TLS clients) |
--unshare-net unless held |
IO.NetListen |
network-bind + network-inbound only. Enforced, split from IO.NetConnect |
Advisory. A network namespace isolates rather than denies: bind() still succeeds, it’s just unreachable |
IO.Process |
process-fork only. process-exec stays in the baseline because sandbox-exec must itself exec the target (denying it: exit 71), so under forge cap run a program without IO.Process can still exec; --cap-sandbox does gate it |
--unshare-pid |
IO.FileRead |
Advisory. dyld must read system libraries before user code runs (a declared read scope is reported as advisory too) | Enforced. An allow-list mount namespace (--ro-bind-try on only the loader’s paths and the binary); anything else is absent, not just forbidden. A granted read scoped to directories gets the same namespace plus a read-only bind of each scope |
IO.Clock, IO.Spawn, IO.Console, IO.Random, IO.Foreign(.Blocking) |
Advisory everywhere, both platforms. No way to tell each one 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 truly 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 IO capability the activated function declares is subsumed by a capability listed in the policy; a capability outside policy (ERR cap_policy <cap>) aborts. A capability outside theIOlattice is not checked: a proof capability (Session.Live,ClusterNode.Live,Actor.Introspect, your ownproof cap) carries no IO authority, only its declaring module can mint it, and the IO its dictionary performs is charged to the code that minted it (a session’s, to the runner). This is why a role body, whose own caps always include the session it is handed, hot patches under a policy of IO caps. Calling foreign code is chargedIO.Foreign, which is checked.
Per-role closures
A changed function’s own capabilities miss one case: a patch that only calls an existing, more powerful helper. Its own caps stay narrow while what it can reach widens. For code that runs as a protocol role with a grant (role Cons needs IO.Console, see Per-role grants), the node checks the role’s full capability closure as well: everything the role’s code reaches, from the same solve the compiler’s role-grant check runs.
- The manifest.
--compile-sowrites oneROLE <Proto.Role> caps=<closure>line per granted role, IO capabilities only, normalized and sorted, with each capability’s reach chain (via=IO.FileWrite:body>cons>save). - The client gate.
forge deploy hotcompares each role’s closure with the saved baseline. A role whose closure widened stops the deploy unless--grant-capcovers the new capabilities, and the error names the role and the chain. - The node gate. A manifest with
ROLElines is deployed with theACTIVATE6message, which signs one digest per role. The node recomputes each digest from the closure it received (ERR role_cap_tamperon a mismatch, on a signed role the message leaves out, or on a role it adds unsigned) and requires every role closure to fitMARCH_DEPLOY_POLICY(ERR role_cap_policy <role> <cap>), after the function’s own caps. When the policy has aservesline (below), only the closures of the roles it names are checked.
So a node whose policy is generated from a pool’s caps refuses a patch whose role code would reach beyond them, even when the patch passed a --grant-cap on the client. A build with no role grants deploys as before, and an older server that does not know ACTIVATE6 refuses a manifest with roles rather than skipping the check.
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 absent MARCH_DEPLOY_POLICY means permissive: all activations are admitted. This is the default for backward compatibility. A policy file that exists but lists no capability admits only functions that declare no IO capability. A policy constrains what hot-patched functions may do; it does not retroactively constrain the trusted base binary the operator already deployed.
A line serves <Proto.Role> ... (bare serves for none) names the roles the node serves, and the per-role check then covers only their closures. forge host init writes one for each pool, after the pool’s caps and the caps the topology runner charges to its roles. A role the node does not serve is another pool’s, or the control plane’s (Ctl.Agent, Ctl.Control, generated into every node’s main), and is not this policy’s to bound; a policy with no serves line bounds every role.
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, and every role’s declared closure, 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 possesses. Both read. The mechanisms here enforce. - Safety by Construction: how capabilities sit alongside the other safety axes.