Skip to main content

Crate procage

Crate procage 

Source
Expand description

§procage

Process sandboxing for Rust. procage runs a child process in a cage on macOS (Seatbelt, applied through libsandbox rather than the deprecated /usr/bin/sandbox-exec) and on Linux (Landlock, seccomp and namespaces), without helper binaries. It is meant for commands that run untrusted, adaptive code as your user: an LLM agent’s tool calls, a project’s build scripts.

Status. Pre-1.0: the API may change between minor versions. The backends are macos and linux (tiers T1 and T2); each module states its limits, and the threat model lists every known escape class and its status.

Policies are written with policy (the procage-policy crate): a portable intent layer, lowered for each backend.

use procage::native::{Cage, Grants}; // procage::macos or procage::linux
use procage::policy::{AbsPath, Access, Base, FsRule, FsTarget, Policy};

let mut policy = Policy::default();
policy.base = Base::V3; // what ordinary programs and developer tools need
policy.fs.push(FsRule::new(Access::Read, FsTarget::Subpath(AbsPath::new("/tmp/project")?)));

let cage = Cage::new(&policy)?;
let status = cage
    .spawn(&mut std::process::Command::new("/bin/ls"), &Grants::new())?
    .wait()?;

§Lifetime and hygiene

The run ends when the Child handle is dropped or kill_all is called: every process the run started is killed, double-forked descendants included. The child receives only standard input, output and error (hand others over with spawn_with_fds); its environment follows the policy’s EnvPolicy (see hygiene); declaring the tmpdir grant gives each run a private TMPDIR.

§Helpers: becoming the caged command

Sometimes other code starts the process: an MCP client, a terminal, tokio::process::Command with kill_on_drop. It holds the pid, the pipes and the exit status, and it kills the process when it is done. Give that code a small helper program instead of the command. The helper builds the cage and calls Cage::exec, which confines the helper itself and executes the command in its place:

use std::process::Command;
use procage::native::{Cage, Grants};
use procage::policy::{Base, Policy};

let mut policy = Policy::default();
policy.base = Base::V3;
let cage = Cage::new(&policy).unwrap_or_else(|e| panic!("{e}"));
let mut command = Command::new("/usr/bin/env");
command.current_dir("/tmp"); // a directory the policy lets it read
let err = cage.exec(&mut command, &Grants::new()); // returns only on failure
eprintln!("helper: {err}");
std::process::exit(126);

On the other side, start the helper in its own process group and end it by killing the group:

use std::os::unix::process::CommandExt;

let helper = std::process::Command::new("my-helper")
    .arg("--")
    .arg("server")
    .process_group(0)
    .spawn()?;
// … later: kill -9 -<pid> ends the command and the descendants in its group.

The helper must be single-threaded on Linux tier T1 (call exec early in main). On Linux, descendants cannot leave the process group under the default Lifetime::Run, and the command dies with the helper’s parent. On macOS a descendant that starts its own session is not reached by the group kill; use Cage::spawn, whose handle finds it, when that matters. For a command whose descendants start process groups (test runners), use Cage::exec_session: start the helper as a plain child and end the run with end_session(pid) before reaping it. The examples/exec_helper.rs program in the repository is a complete helper.

§Fitting a policy to the host

A host that cannot enforce part of a policy refuses the cage and lists each part as a capability gap: procage never runs a child under a weaker policy on its own. A harness that would rather run with a known, stated limit than not at all names the relaxations it accepts and calls Cage::fit. It gets the cage and the list of what was relaxed, each with what would lift it, to show its user:

use procage::native::{Cage, CageOptions};
use procage::policy::{AbsPath, Access, FsRule, FsTarget, HardDenyClass, Policy, Relax};

let mut policy = Policy::default();
policy.fs.push(FsRule::new(Access::Write, FsTarget::Subpath(AbsPath::new("/w")?)));
policy.fs.push(FsRule::new(Access::Protect, FsTarget::Subpath(AbsPath::new("/w/.git")?)));

let relax = Relax::new()
    .leave_out_protect() // e.g. `.git` inside a writable workspace
    .waive(HardDenyClass::TrustHandoff)
    .signals_same_user(); // Linux older than 6.12
let (cage, relaxed) = Cage::fit(&policy, &CageOptions::default(), &relax)?;
for r in &relaxed {
    eprintln!("sandbox: {r}");
    // fs-protect /w/.git on Linux T2 (Landlock ABI 8): the protect rule is
    // left out; tier T1 (user namespaces) would enforce it
}

Each relaxation has one effect: leave out the protect or hide rule a gap names, waive a hard-deny class, allow no Unix sockets or no network (stricter), or let signals reach every process of the user. Any gap without an accepted relaxation still fails, with every remaining gap. Approximations are a different consent and stay in LowerOptions::allow_degrade.

§Violations: what the sandbox denied, and why

A denied operation reaches the caged program as EPERM, which it may print, swallow or misreport. A cage built with CageOptions::with_violations also tells the harness: after the child exits, Child::violations lists each denied operation with the process, the target and the kind of rule that denied it, so the harness can explain the failure or ask its user for a grant.

use std::process::Command;
use procage::native::{Cage, CageOptions, Grants};

let cage = Cage::with_options(&policy, &CageOptions::default().with_violations())?;
let mut child = cage.spawn(&mut Command::new("/usr/bin/make"), &Grants::new())?;
child.wait()?;
for v in &child.violations() {
    eprintln!("sandbox: {v}");
    // make (pid 812) was denied file-write-create /Users/a/out.o: not granted by the policy
}

A record says whether it is complete and what it can show (Coverage): every denial on macOS, every refused system call on Linux. Identical denials are merged into one entry with a count (Violation::repeats). Reporting is diagnostic. Enforcement never depends on it.

§Receipts: evidence of how a run was caged

When a run has exited, its child handle gives a Receipt: the policy’s digest (policy_digest), the preset and base, the backend and tier, the OS release and build or the kernel release, everything the cage accepted short of the policy (approximations, fit relaxations, waivers, and one field that says whether there was any), the violation record’s coverage and count, and how the run ended. Store it with the command’s output:

use procage::native::{Cage, Grants};
use procage::policy::presets;

let cage = Cage::new(&presets::minimal::v3())?;
let mut child = cage.spawn(&mut std::process::Command::new("/bin/ls"), &Grants::new())?;
child.wait()?;
let receipt = child.receipt().expect("the child exited");
println!("{}", receipt.to_json());

A receipt is the caller’s own unsigned record, not an attestation: keep it outside every path a later run may write. An empty, complete violation record in it does not prove that nothing was denied (macOS reports are best effort). It holds no environment value, argument or path unless the cage was built with CageOptions::with_policy_in_receipt, which adds every path of the policy to each receipt. A reader refuses a newer version or an unknown field. See the receipt module for the details.

§Audits: what path rules cannot stop

Before every spawn, a protected, hidden or credential file with a second name (a hard link) outside the protected set refuses the spawn (SpawnError::HardLinks; see CageOptions::with_link_exemption and LinkAudit). With CageOptions::with_handoff_audit, Child::audit lists the trust-handoff files, symbolic links and repositories a run created or changed. See the audit module.

§Egress through an allowlist (proxy)

With the proxy feature, a Network::Proxy policy and CageOptions::with_proxy, each run reaches only the hosts an allowlist names, through a proxy of its own (procage::proxy), and no other route: macOS and Linux tier T1; a gap on T2. Refusals are in the run’s violations.

§Async (tokio)

With the tokio feature, Cage::spawn_async takes a tokio::process::Command and returns an AsyncChild that dereferences to tokio::process::Child: await its exit and read its pipes on the runtime. The checks, the child sequence and the errors are the std path’s; dropping the handle or awaiting AsyncChild::kill_all ends the run as it does for native::Child. AsyncChild::violation_stream yields the run’s violations as they happen and ends when the record is complete. Without a runtime on the calling thread the spawn fails with SpawnError::NoRuntime before forking. Without the feature procage has no async dependency.

Read the threat model for the adversary, the scope, the limits of an OS sandbox and the status of every known escape class.

Re-exports§

pub use macos as native;
pub use audit::Audit;
pub use audit::HandoffFinding;
pub use audit::HandoffKind;
pub use audit::LinkAudit;
pub use audit::LinkFinding;
pub use receipt::Receipt;
pub use receipt::ReceiptApproximation;
pub use receipt::ReceiptAudit;
pub use receipt::ReceiptBackend;
pub use receipt::ReceiptCoverage;
pub use receipt::ReceiptError;
pub use receipt::ReceiptExit;
pub use receipt::ReceiptLift;
pub use receipt::ReceiptPath;
pub use receipt::ReceiptRelaxation;
pub use receipt::ReceiptViolations;
pub use receipt::policy_digest;
pub use violation::ViolationStream;
pub use violation::Coverage;
pub use violation::DeniedBy;
pub use violation::Violation;
pub use violation::Violations;
pub use procage_policy as policy;
pub use procage_proxy as proxy;

Modules§

audit
Audits of what path rules cannot stop.
hygiene
Process hygiene shared by every backend.
macos
Spawning a caged child on macOS (Seatbelt).
receipt
Receipts: the record of how one run was caged.
threat_model
Threat model
violation
Violations: the operations the sandbox denied a run, and why.

Structs§

Pty
The controller side of a caged child’s terminal, taken with Child::take_pty.

Enums§

Suggestion
One policy change. Suggestion::weakens says whether it lifts a protection rather than granting.

Functions§

suggest
The narrowest change to policy that would allow the operation violation reports on the next spawn, or None.