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;