Rust crate · macOS and Linux · pre-1.0

Cage the commands your program runs on someone else’s behalf.

procage runs a child process under an OS sandbox: Seatbelt on macOS, without sandbox-exec, and Landlock, seccomp and namespaces on Linux. It is built for commands that run untrusted, adaptive code as your user, such as an LLM agent’s tool calls or a project’s build scripts.

Status: pre-1.0: the API may change between minor versions.

use std::process::Command;
use procage::native::{Cage, CageOptions, Grants};
use procage::policy::{presets, AbsPath, LowerOptions};

// Read the workspace; no writes, no network. Secrets,
// git hooks and launch agents are denied in any policy.
let workspace = AbsPath::new("/path/to/project")?;
let policy = presets::agent_readonly::v3(&workspace);

let options = CageOptions::default()
    .with_lower(LowerOptions::default().accept_spawn_snapshot());
let cage = Cage::with_options(&policy, &options)?;

let mut child = cage.spawn(
    Command::new("/usr/bin/grep")
        .args(["-rn", "TODO", "."])
        .current_dir("/path/to/project"),
    &Grants::new(),
)?;
let status = child.wait()?;
// Dropping `child` kills every process the run started.

A preset, prepared once and spawned. Linux hosts that cannot enforce part of it refuse with a list of gaps.

Why procage

An agent that follows injected instructions, or a malicious build script, runs with your user id. It can read ~/.ssh, plant a git hook your shell later runs, register a launch agent, or keep a daemon alive after you thought it ended. procage is meant to keep that code away from your secrets, from files outside the paths you grant, from your terminal and session, and from persistence locations.

One policy, lowered per OS

You write a typed, deny-by-default policy once. procage lowers it to a Seatbelt profile or a Linux plan, and every construct is exact, an approximation you accept by name, or a typed capability gap.

Policies

Never silently weaker

A host that cannot enforce part of a policy refuses the cage and lists every gap. Cage::fit relaxes only what you name and tells you what would lift each relaxation.

Fitting a policy

Know what was denied, and why

Instead of a bare “Operation not permitted”, a reporting cage lists each denied operation with the process, the path and the rule behind it (not granted, hidden, protected, a hard-deny class), live or after the exit: every denial on macOS, every refused system call on Linux.

Violations

Explain a denial, diff a change

Ask why a path can or cannot be read, written or executed: the deciding rule, and the narrowest change that would allow it. Diff two policies to see every rule one allows that the other does not. The kernel checks every answer on every CI lane.

Explain and diff

A hard-deny tier in every policy

Secrets, trust-handoff files such as git hooks and shell startup files, persistence locations, privilege changes and more are denied last, in every policy, and waivable only by class name.

Hard-deny classes

Lifetime and hygiene

Inherited descriptors are closed, secret-looking variables dropped, the terminal kept out of reach, and every process the run started is killed when the run ends, double-forked daemons included.

Lifetime and hygiene

Receipts

Every finished run says how it was caged: the policy digest, backend and tier, the OS build, and everything accepted short of the policy.

Receipts

Escape classes with canaries

Every known escape class has a stable id and a status in the threat model. A class is marked handled only when caged canaries on a real kernel show the refusal.

Threat model

No helper binaries

The library applies the sandbox in the forked child with system calls only. Or a small helper becomes the caged command with Cage::exec, for code that starts and kills processes itself.

Running helpers

Platforms

procage measures what the host offers and never falls back to running unsandboxed. What a Linux host gets depends on its tier and Landlock ABI.

What procage uses on each platform
PlatformMechanismNeedsNotable limits today
macOSSeatbelt profile compiled and applied through libsandbox’s private interface, resolved at run timeA process that is not itself sandboxedNo pseudo-terminals; base profiles curated on macOS 26.6.2
Linux tier T1User, mount, IPC, UTS and network namespaces, mounts, Landlock, seccompUnprivileged user namespaces, Landlock ABI 1 (Linux 5.13) or laterNo PID namespace; no loopback networking; glob rules are gaps
Linux tier T2Landlock and seccomp onlyLandlock ABI 1 or later (for example Docker’s default settings)As T1, no proxy-only networking, and cannot protect or hide a path inside a writable tree

Linux tests run on eight mainline kernels from 5.15 to 7.3.1 (Landlock ABI 1 to 11) and in Docker on both tiers. Details: macOS backend, Linux backend and tiers.

What it is not

An OS sandbox reduces the risk from adaptive same-user code; it does not remove it. The kernel and OS services stay reachable, and a bug in them is an escape. When that is unacceptable, use a microVM, or a microVM with procage inside it for finer-grained policy.

The threat model lists every escape class and its status, and what the calling harness must handle itself.

Links