From cdd809ca7b5d39e3feaa4f8abe61a05c7a981f9f Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Fri, 16 Oct 2026 10:01:57 -0400 Subject: [PATCH] =?UTF-8?q?sandbox:=20an=20S1=20executor=20=E2=80=94=20a?= =?UTF-8?q?=20native=20program=20confined=20by=20the=20operating=20system,?= =?UTF-8?q?=20earning=20the=20rung=20by=20probe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ladder had no executor between a bare child process (S0) and a managed runtime (S2). `sandbox-exec-os` runs a native program under the operating system's sandbox, with a deny-by-default policy derived from the unit's grants: - the program may read and write exactly the granted storage paths; - it has no network; - it cannot spawn a process; - it reaches no device beyond the host CPU. Why S1 and no more. S1's guarantee is that no resource is reachable without a capability, and that holds for every resource grants govern. A native process holds a stated residue: the system libraries read-only, the root directory's listing, /dev/null, the clock and randomness. Because the clock and randomness cannot be withheld, a grant that withholds either is refused, never run with a capability it denies. So are network grants (egress cannot be limited to named endpoints) and everything that needs S2: subprocesses, browser, display, accelerators, key-value store. S2 itself needs fuel and memory caps, which a native process cannot have. The rung is earned per host. A probe, run once per process, runs a program under a policy that grants nothing. It must succeed at a control run, and be denied a file outside the policy, a write, a subprocess and a loopback connection to a listener the probe opened. Only then is the tier S1, with SPEC §7.9's mechanism: `seatbelt/` and a commitment to the exact policy applied. Anywhere else the program runs unconfined at S0 with no mechanism. Linux is S0 for now: a namespace sandbox there also needs a system-call filter to enforce "no subprocess", and the fleet's Linux hosts refuse the network namespace, so it could not be verified. Energy is the child's share of the host's CPU reading (`run_attributed`): ModelBased where that reading is a hardware one, Estimator otherwise. sandbox-build: a command step runs confined at S1 where the confinement holds and can enforce the step's grants, sealed with its mechanism. Any other step falls back to the existing unconfined child process (S0), so today's steps, whose grants withhold the clock, are unchanged. CI: the macOS energy job runs sandbox-exec-os and fails unless the S1 branch ran (the probe's confinement line and a denied read). Every other runner takes the S0 branch, which the same tests assert. Checked on the build host (macOS 27.0.1): `seatbelt/27.0.1` probes S1. A granted copy succeeds, a read outside the grants is denied, run_and_seal seals S1 with the mechanism, and a unit withholding the clock is refused before it runs. The sandbox-rs workspace passes 407 tests, conformance passes all classes, and clippy is clean on both crates. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/standards-build.yml | 17 + sandbox/sandbox-rs/Cargo.lock | 13 + sandbox/sandbox-rs/Cargo.toml | 2 + .../crates/sandbox-build/Cargo.toml | 1 + .../crates/sandbox-build/src/lib.rs | 99 +++- .../crates/sandbox-exec-os/Cargo.toml | 18 + .../crates/sandbox-exec-os/src/lib.rs | 508 ++++++++++++++++++ .../crates/sandbox-exec-os/tests/confined.rs | 134 +++++ 8 files changed, 773 insertions(+), 19 deletions(-) create mode 100644 sandbox/sandbox-rs/crates/sandbox-exec-os/Cargo.toml create mode 100644 sandbox/sandbox-rs/crates/sandbox-exec-os/src/lib.rs create mode 100644 sandbox/sandbox-rs/crates/sandbox-exec-os/tests/confined.rs diff --git a/.github/workflows/standards-build.yml b/.github/workflows/standards-build.yml index be065846c..8fd496328 100644 --- a/.github/workflows/standards-build.yml +++ b/.github/workflows/standards-build.yml @@ -508,6 +508,23 @@ jobs: # process tree, spawned suspended and read before it is reaped. Its # real-engine tests are gated to Apple Silicon macOS; each run they # meter prints this line. + # sandbox-exec-os earns S1 only where its probe shows the operating + # system's sandbox confining a program on this host, and that branch + # exists only on macOS: every other runner takes the S0 branch. The step + # fails unless the test printed the confinement it found, the only proof + # the S1 branch ran rather than the S0 one passing in its place. + - name: sandbox-exec-os (Seatbelt confinement at S1) + shell: bash + working-directory: sandbox/sandbox-rs + env: + SANDBOX_EXEC_OS_EXPECT_S1: "1" + run: | + set -o pipefail + log=$(mktemp) + cargo test -p sandbox-exec-os -- --nocapture 2>&1 | tee "$log" + grep -q 'sandbox-exec-os: confinement seatbelt/' "$log" + grep -q 'denied a read outside the grants' "$log" + - name: sandbox-proof (local engine process tree) shell: bash working-directory: sandbox/sandbox-rs diff --git a/sandbox/sandbox-rs/Cargo.lock b/sandbox/sandbox-rs/Cargo.lock index bd6e3eea2..93c46159e 100644 --- a/sandbox/sandbox-rs/Cargo.lock +++ b/sandbox/sandbox-rs/Cargo.lock @@ -1698,6 +1698,7 @@ dependencies = [ "sandbox-attest", "sandbox-capability", "sandbox-exec-microvm", + "sandbox-exec-os", "sandbox-exec-wasm", "sandbox-meter", "sandbox-runtime", @@ -1761,6 +1762,18 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "sandbox-exec-os" +version = "0.1.0" +dependencies = [ + "ed25519-dalek", + "sandbox-attest", + "sandbox-capability", + "sandbox-meter", + "sandbox-runtime", + "sandbox-schema", +] + [[package]] name = "sandbox-exec-wasm" version = "0.1.0" diff --git a/sandbox/sandbox-rs/Cargo.toml b/sandbox/sandbox-rs/Cargo.toml index 3f5e36e79..5f732386b 100644 --- a/sandbox/sandbox-rs/Cargo.toml +++ b/sandbox/sandbox-rs/Cargo.toml @@ -9,6 +9,7 @@ members = [ "crates/sandbox-tee", "crates/sandbox-exec-wasm", "crates/sandbox-exec-microvm", + "crates/sandbox-exec-os", "crates/sandbox-conformance", "crates/sandbox-proof", "crates/sandbox-proof-wasm", @@ -34,6 +35,7 @@ sandbox-runtime = { path = "crates/sandbox-runtime" } sandbox-tee = { path = "crates/sandbox-tee" } sandbox-exec-wasm = { path = "crates/sandbox-exec-wasm" } sandbox-exec-microvm = { path = "crates/sandbox-exec-microvm" } +sandbox-exec-os = { path = "crates/sandbox-exec-os" } sandbox-conformance = { path = "crates/sandbox-conformance" } sandbox-proof = { path = "crates/sandbox-proof" } sandbox-build = { path = "crates/sandbox-build" } diff --git a/sandbox/sandbox-rs/crates/sandbox-build/Cargo.toml b/sandbox/sandbox-rs/crates/sandbox-build/Cargo.toml index 643c1b55f..dd59159eb 100644 --- a/sandbox/sandbox-rs/crates/sandbox-build/Cargo.toml +++ b/sandbox/sandbox-rs/crates/sandbox-build/Cargo.toml @@ -19,6 +19,7 @@ sandbox-attest.workspace = true sandbox-runtime.workspace = true sandbox-exec-wasm.workspace = true sandbox-exec-microvm.workspace = true +sandbox-exec-os.workspace = true ed25519-dalek.workspace = true serde = { workspace = true } serde_json.workspace = true diff --git a/sandbox/sandbox-rs/crates/sandbox-build/src/lib.rs b/sandbox/sandbox-rs/crates/sandbox-build/src/lib.rs index 248f8c2dd..55359fef1 100644 --- a/sandbox/sandbox-rs/crates/sandbox-build/src/lib.rs +++ b/sandbox/sandbox-rs/crates/sandbox-build/src/lib.rs @@ -22,6 +22,7 @@ use sandbox_capability::Grants; use sandbox_exec_microvm::firecracker::{FirecrackerConfig, ProcessVmm, Vmm}; use sandbox_exec_microvm::power::NoMeter; use sandbox_exec_microvm::MicroVmExecutor; +use sandbox_exec_os::OsExecutor; use sandbox_runtime::{ grants_content_id, unit_content_id, Executor, Host, RunError, TierRequest, Unit, }; @@ -466,11 +467,14 @@ fn resolve_candidate( Ok((rec, true)) } -/// Resolve a **command** step: run the program in the S5 microVM (a real -/// subprocess via `ProcessVmm`; Firecracker on Linux+KVM in production), meter -/// it, and seal at S5 (an escalation above the grant floor — the host attests it -/// ran in the microVM). L0 if the identical command is cached. Returns the -/// record and whether it was a fresh execution. +/// Resolve a **command** step: run the program, meter it, and seal it at the +/// rung it actually reached. Where this host's operating-system confinement +/// holds and can enforce the step's grants, the command runs confined and +/// seals **S1** with its mechanism (`sandbox-exec-os`). Otherwise it runs as +/// an ordinary child process under `ProcessVmm` (Firecracker on Linux+KVM in +/// production) and seals the tier that reports, S0 on a host without one. L0 +/// if the identical command is cached. Returns the record and whether it was a +/// fresh execution. #[allow(clippy::too_many_arguments)] fn resolve_command( host: &Host, @@ -503,20 +507,34 @@ fn resolve_command( return Ok((rec, false)); } - let vmm = ProcessVmm::new(program.to_string(), args.to_vec()); - // The tier is read off the executor, never asserted here. `ProcessVmm::new` - // supervises the build command as an ordinary child process on the host, so - // on a host without hardware virtualization this is S0 — and the receipt - // will say S0. Sealing a rung the run did not reach would make the receipt - // worthless precisely where it is load-bearing. - let attained = vmm.attained_tier(); - let exec = MicroVmExecutor::new(Box::new(vmm), template.clone(), Box::new(NoMeter)); let unit = Unit::new(image, grants); - let report = exec.run(&unit).map_err(|e| BuildError::Run(e.into()))?; + // The tier is read off the executor that ran the command, never asserted + // here: sealing a rung the run did not reach would make the receipt + // worthless precisely where it is load-bearing. The confined executor is + // used only where its confinement holds on this host AND it can enforce + // these grants; a step whose grants it cannot enforce (a subprocess, the + // network, a withheld clock) is never run confined with a capability its + // grant denies, and falls back to the ordinary child process. + let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("/")); + let confined = OsExecutor::new(program.to_string(), args.to_vec(), cwd); + let (attained, report, mechanism) = + if OsExecutor::confinement().is_some() && confined.policy(grants).is_ok() { + let report = confined.run(&unit).map_err(|e| BuildError::Run(e.into()))?; + (confined.tier(), report, confined.mechanism()) + } else { + // `ProcessVmm::new` supervises the command as an ordinary child + // process on the host: S0 on a host without hardware virtualization. + let vmm = ProcessVmm::new(program.to_string(), args.to_vec()); + let attained = vmm.attained_tier(); + let exec = MicroVmExecutor::new(Box::new(vmm), template.clone(), Box::new(NoMeter)); + let report = exec.run(&unit).map_err(|e| BuildError::Run(e.into()))?; + (attained, report, None) + }; let unit_id = unit_content_id(&image_id, &grants_id); - // The host attests the run at the tier the executor reports reaching. - let sealed = host.seal_run( + // The host attests the run at the tier the executor reports reaching, with + // the mechanism that executor committed to (none below S1). + let sealed = host.seal_run_with_mechanism( unit_id, image_id.clone(), grants, @@ -526,6 +544,7 @@ fn resolve_command( [0; 8], started, started + 1, + mechanism, )?; sealed .receipt @@ -1067,6 +1086,46 @@ mod tests { assert!(!verify_build_receipt(&bad)); } + /// A command step whose grants the operating-system confinement can + /// enforce — the clock and randomness, a granted directory to write — runs + /// confined and seals S1 with its mechanism where that confinement holds on + /// this host, and the ordinary S0 child process everywhere else. + #[test] + fn a_confinable_command_step_seals_s1_where_the_confinement_holds() { + use sandbox_capability::HostGrant; + use sandbox_exec_wasm::WasmExecutor; + let host = host(); + let exec = WasmExecutor::with_defaults().unwrap(); + let key = SigningKey::from_bytes(&[89u8; 32]); + let mut cache = BuildCache::new(); + let dir = std::env::temp_dir().join(format!("sandbox-build-os-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let dir = dir.canonicalize().unwrap(); + let mut grants = Grants { + host: HostGrant { logging: true, clock: true, random: true, inference: false }, + ..cpu_grants() + }; + grants.storage.write = vec![dir.to_str().unwrap().into()]; + let task = BuildTask { + intent: "write a build artifact".into(), + steps: vec![BuildStep::command( + "touch", + "/usr/bin/touch", + vec![dir.join("artifact").to_str().unwrap().into()], + grants, + )], + }; + let signed = + run_build(&host, &exec, "steward.build", &task, &mut cache, &key, zero_id(), 1_800_000).unwrap(); + let s = &signed.receipt.steps[0]; + assert_eq!(s.outcome, "Completed"); + assert!(dir.join("artifact").exists()); + let want = if OsExecutor::confinement().is_some() { Tier::S1 } else { Tier::S0 }; + assert_eq!(s.tier, want); + assert!(verify_build_receipt(&signed)); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn a_command_step_seals_the_tier_it_actually_reached() { use sandbox_exec_wasm::WasmExecutor; @@ -1078,9 +1137,11 @@ mod tests { // A real command (`true`) is supervised by `ProcessVmm::new` — an // ordinary child process on the host, not a guest under a VMM. It is // attested and carries op provenance (the path for non-wasm source - // builds), but the rung it seals is the floor, S0. This crate never - // constructs `supervising_vmm`, so the tier here does not vary with the - // host: a build receipt from this path claims S0 everywhere. + // builds), but the rung it seals is the floor, S0. Its grants withhold + // the clock and randomness, which no operating-system confinement can + // take from a native process, so the confined executor will not run it + // either; and this crate never constructs `supervising_vmm`. So the + // tier here does not vary with the host: S0 everywhere. let task = BuildTask { intent: "run the test suite".into(), steps: vec![BuildStep::command("test", "true", vec![], cpu_grants()) diff --git a/sandbox/sandbox-rs/crates/sandbox-exec-os/Cargo.toml b/sandbox/sandbox-rs/crates/sandbox-exec-os/Cargo.toml new file mode 100644 index 000000000..1bb90c178 --- /dev/null +++ b/sandbox/sandbox-rs/crates/sandbox-exec-os/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "sandbox-exec-os" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +description = "Sandbox — an S1 executor: a native program confined by the operating system's own sandbox, earning its rung only when a probe on this host shows the confinement denies what the grants do not name." + +[dependencies] +sandbox-schema.workspace = true +sandbox-capability.workspace = true +sandbox-meter.workspace = true +sandbox-attest.workspace = true +sandbox-runtime.workspace = true + +[dev-dependencies] +ed25519-dalek.workspace = true diff --git a/sandbox/sandbox-rs/crates/sandbox-exec-os/src/lib.rs b/sandbox/sandbox-rs/crates/sandbox-exec-os/src/lib.rs new file mode 100644 index 000000000..df5604483 --- /dev/null +++ b/sandbox/sandbox-rs/crates/sandbox-exec-os/src/lib.rs @@ -0,0 +1,508 @@ +//! # sandbox-exec-os — an S1 executor: a native program, confined by the operating system +//! +//! The rungs below S2 had no executor between a bare child process (S0) and +//! a managed runtime (S2, `sandbox-exec-wasm`). This crate runs a **native +//! program** under the operating system's own sandbox, with a policy derived +//! from the unit's grants, and claims **S1** only where it can show the +//! policy holds. +//! +//! ## Why S1, and only S1 +//! +//! S1's guarantee (`spec/tiers.md`) is that no resource is reachable without +//! a capability for it. Here the resources are the ones grants govern — +//! storage, network, subprocesses, devices, host calls — and the policy is +//! **deny by default**: the program may read and write exactly the paths its +//! storage grant names, and nothing else; it has no network, cannot spawn a +//! process, and reaches no device beyond the host CPU. +//! +//! S2 is out of reach by construction: it needs a managed runtime with fuel +//! metering, memory caps and no raw syscall surface, and a native process has +//! none of them. So a unit whose grants need S2 — subprocesses (§5.5), a +//! browser (§5.6), a display (§5.7), an accelerator (§5.1) — is refused, never +//! run at a rung it did not reach. +//! +//! ## What the confinement leaves reachable +//! +//! A native process cannot run at all without part of its host, and that +//! part is stated rather than hidden: +//! +//! - the system libraries and frameworks, read-only, and the root directory's +//! own listing (the loader reads it); +//! - `/dev/null`; +//! - the clock, and the system's randomness: no operating-system sandbox can +//! withhold them from a native process. +//! +//! The last point decides which units can be confined honestly. A grant that +//! withholds the clock or randomness (`host.clock` or `host.random` false) +//! asks for something this executor cannot enforce, so such a unit is refused +//! ([`Unenforceable::HostCall`]) rather than run with a capability its grant +//! denies. Nor can it limit egress to named endpoints, so any network grant is +//! refused too; with no network grant the program has no network at all. +//! +//! ## The rung is earned on this host +//! +//! [`OsExecutor::tier`] is S1 only when a probe, run once per process, shows +//! the confinement working here: a program runs under a policy that grants +//! nothing, and is denied a file outside it, a write, a subprocess, and (where +//! `nc` exists) a connection to a listener the probe opened. Anywhere the +//! probe fails, or the platform has no backend, the executor runs the program +//! unconfined and says **S0**, with no mechanism: a mechanism at S0 is +//! incoherent (SPEC §7.9). +//! +//! The backend is macOS's sandbox (Seatbelt), driven through `sandbox-exec`. +//! There is no Linux backend yet. An unprivileged namespace sandbox there +//! would also need a system-call filter to enforce "no subprocess", and the +//! fleet's Linux hosts could not run one to verify it (their user-namespace +//! policy refuses the network namespace). Until one can be verified, Linux is +//! S0. +//! +//! ## What the attestation carries +//! +//! At S1, [`Executor::mechanism`] commits to the runtime (`seatbelt/`) and the exact policy text applied to the last run (SPEC §7.9). +//! Energy is the child's attributed share of the host's CPU reading +//! ([`sandbox_meter::run_attributed`]): `ModelBased` where that reading is a +//! hardware one, `Estimator` otherwise. + +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; + +use sandbox_attest::{DeviceIdentity, Mechanism, Outcome}; +use sandbox_capability::Grants; +use sandbox_meter::{DeviceEnergy, PowerMeter, Provenance}; +use sandbox_runtime::{ExecError, Executor, RunReport, Unit}; +use sandbox_schema::{DeviceClass, Tier}; + +/// The program that applies a Seatbelt policy and then runs the command. +const SANDBOX_EXEC: &str = "/usr/bin/sandbox-exec"; + +/// Why a unit's grants cannot be enforced by this executor. Each is a +/// refusal: the unit is never run with a capability its grants deny, or at a +/// rung below the one its grants need. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Unenforceable { + /// An accelerator class: reaching one needs a brokered device interface + /// (S2) or device assignment (S5). + Device(DeviceClass), + /// Network egress or ingress: the confinement cannot limit traffic to the + /// granted endpoints, only deny it all. + Network, + /// Subprocesses: spawning is untrusted-execution class (S2, SPEC §5.5). + Process, + /// Browser automation (S2, SPEC §5.6). + Browser, + /// A framebuffer or desktop (S2, SPEC §5.7). + Display, + /// A key-value store: reached through host calls this executor does not + /// broker. + Kv, + /// A host call the grant withholds and a native process holds anyway + /// (`"clock"`, `"random"`), or one it grants and this executor cannot + /// provide (`"inference"`). + HostCall(&'static str), + /// A storage path that is relative, cannot be resolved, or cannot be + /// written into a policy safely. + Path(String), + /// The program cannot be found as an executable file. + Program(String), +} + +impl std::fmt::Display for Unenforceable { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Device(c) => write!(f, "device class {c:?} needs a brokered device interface"), + Self::Network => write!(f, "network grants cannot be limited to the granted endpoints"), + Self::Process => write!(f, "spawning subprocesses needs a brokered boundary (S2)"), + Self::Browser => write!(f, "browser automation needs S2"), + Self::Display => write!(f, "display access needs S2"), + Self::Kv => write!(f, "key-value access needs a brokered host call"), + Self::HostCall(c) => write!(f, "host call {c:?} cannot be enforced for a native process"), + Self::Path(p) => write!(f, "storage path {p:?} cannot be confined"), + Self::Program(p) => write!(f, "program {p:?} is not an executable file"), + } + } +} + +/// The operating-system confinement this host offers, as the probe found it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Confinement { + /// The runtime, name and version: `seatbelt/`. + pub runtime: String, +} + +/// A native program run under the operating system's sandbox (see the crate +/// docs). The program and its arguments are the executor's; the unit +/// supplies the grants the policy is derived from. +pub struct OsExecutor { + program: String, + args: Vec, + cwd: PathBuf, + /// The policy applied to the last confined run, for [`Executor::mechanism`]. + last_profile: Mutex>, +} + +impl OsExecutor { + /// Run `program args` in `cwd`. + pub fn new(program: impl Into, args: Vec, cwd: impl Into) -> Self { + OsExecutor { program: program.into(), args, cwd: cwd.into(), last_profile: Mutex::new(None) } + } + + /// The confinement this host offers, when the probe showed it working; + /// `None` elsewhere. Probed once per process. + pub fn confinement() -> Option<&'static Confinement> { + static PROBED: OnceLock> = OnceLock::new(); + PROBED.get_or_init(probe).as_ref() + } + + /// Whether this executor can enforce `grants`, and if so the policy it + /// would apply. Pure: it reads the file system to resolve paths but runs + /// nothing. + pub fn policy(&self, grants: &Grants) -> Result { + if let Some(c) = grants.compute.devices.iter().find(|d| !d.is_host_cpu()) { + return Err(Unenforceable::Device(*c)); + } + if !grants.network.outbound.is_empty() || grants.network.inbound || !grants.network.credentials.is_empty() { + return Err(Unenforceable::Network); + } + if grants.process.exec || !grants.process.programs.is_empty() { + return Err(Unenforceable::Process); + } + if grants.browser.enabled { + return Err(Unenforceable::Browser); + } + if grants.display.enabled { + return Err(Unenforceable::Display); + } + if grants.kv.read || grants.kv.write { + return Err(Unenforceable::Kv); + } + if !grants.host.clock { + return Err(Unenforceable::HostCall("clock")); + } + if !grants.host.random { + return Err(Unenforceable::HostCall("random")); + } + if grants.host.inference { + return Err(Unenforceable::HostCall("inference")); + } + let program = resolve_program(&self.program).ok_or_else(|| Unenforceable::Program(self.program.clone()))?; + let mut reads = Vec::new(); + for p in &grants.storage.read { + reads.push(policy_path(p)?); + } + let mut writes = Vec::new(); + for p in grants.storage.write.iter().chain(&grants.storage.delete) { + writes.push(policy_path(p)?); + } + Ok(seatbelt_policy(&program, &reads, &writes)) + } + + /// Run `program args` with `policy` applied (or unconfined when `None`), + /// metered. + fn run_metered(&self, policy: Option<&str>) -> Result<(bool, sandbox_meter::Attribution), ExecError> { + let mut meter = PowerMeter::detect(); + let mut argv: Vec<&str> = Vec::new(); + let program = match policy { + Some(p) => { + argv.extend(["-p", p, self.program.as_str()]); + SANDBOX_EXEC + } + None => self.program.as_str(), + }; + argv.extend(self.args.iter().map(String::as_str)); + let (ok, _output, attribution) = sandbox_meter::run_attributed(&mut meter, program, &argv, &self.cwd) + .map_err(|e| ExecError::Unavailable(format!("spawn {program}: {e}")))?; + Ok((ok, attribution)) + } +} + +impl Executor for OsExecutor { + /// S1 where the probe showed the confinement working on this host; S0 + /// everywhere else. Never asserted by a caller. + fn tier(&self) -> Tier { + if Self::confinement().is_some() { + Tier::S1 + } else { + Tier::S0 + } + } + + fn run(&self, unit: &Unit) -> Result { + let confined = Self::confinement(); + let policy = match confined { + Some(_) => { + let policy = self.policy(unit.grants).map_err(|e| { + ExecError::Unavailable(format!("{e}: this executor will not run the unit at S1")) + })?; + *self.last_profile.lock().expect("profile lock") = Some(policy.clone()); + Some(policy) + } + None => { + *self.last_profile.lock().expect("profile lock") = None; + None + } + }; + let (ok, a) = self.run_metered(policy.as_deref())?; + // The child's attributed share of the host's CPU reading: a model + // over a hardware reading where there is one, an estimate otherwise. + let energy = DeviceEnergy { + class: DeviceClass::Cpu, + measured_uj: None, + estimated_uj: a.attributed_uj, + provenance: if a.attributed { Provenance::ModelBased } else { Provenance::Estimator }, + }; + Ok(RunReport { + device_energy: vec![energy], + device_identity: vec![DeviceIdentity { + class: DeviceClass::Cpu, + model: "host".into(), + driver: confined.map_or_else(|| "process".to_string(), |c| c.runtime.clone()), + }], + outcome: if ok { Outcome::Completed } else { Outcome::Failed }, + journal: None, + storage: None, + }) + } + + /// At S1, the runtime and the policy applied to the last run; `None` at + /// S0 and before any confined run. + fn mechanism(&self) -> Option { + let c = Self::confinement()?; + let policy = self.last_profile.lock().expect("profile lock").clone()?; + Some(Mechanism::commit(c.runtime.clone(), policy.as_bytes(), &[])) + } +} + +/// `program` as the absolute, symlink-resolved path the policy names: as +/// given when it contains a `/`, else the first executable file of that name +/// on `PATH`. +fn resolve_program(program: &str) -> Option { + let found = if program.contains('/') { + Some(PathBuf::from(program)) + } else { + std::env::var_os("PATH") + .into_iter() + .flat_map(|p| std::env::split_paths(&p).collect::>()) + .map(|d| d.join(program)) + .find(|c| c.is_file()) + }?; + let real = found.canonicalize().ok()?; + let s = real.to_str()?.to_string(); + policy_safe(&s).then_some(s) +} + +/// A storage path as the policy names it: absolute, symlinks resolved (the +/// sandbox matches resolved paths, so `/tmp` must become `/private/tmp`). A +/// path that does not exist yet resolves through its parent. +fn policy_path(p: &str) -> Result { + let path = Path::new(p); + if !path.is_absolute() { + return Err(Unenforceable::Path(p.into())); + } + let resolved = match path.canonicalize() { + Ok(r) => r, + Err(_) => { + let parent = path.parent().and_then(|d| d.canonicalize().ok()); + match (parent, path.file_name()) { + (Some(d), Some(name)) => d.join(name), + _ => return Err(Unenforceable::Path(p.into())), + } + } + }; + match resolved.to_str() { + Some(s) if policy_safe(s) => Ok(s.to_string()), + _ => Err(Unenforceable::Path(p.into())), + } +} + +/// Whether `s` can be written into a policy string literal as it is: no quote +/// or backslash to escape, no control character. +fn policy_safe(s: &str) -> bool { + !s.chars().any(|c| c == '"' || c == '\\' || c.is_control()) +} + +/// The Seatbelt policy for `program` reading `reads` and writing `writes`: +/// deny by default, then the substrate a native process needs (see the crate +/// docs), then exactly the granted paths. +fn seatbelt_policy(program: &str, reads: &[String], writes: &[String]) -> String { + let mut p = String::from("(version 1)\n(deny default)\n"); + p.push_str(&format!("(allow process-exec (literal \"{program}\"))\n")); + p.push_str(&format!( + "(allow file-read* (literal \"/\") (literal \"/dev/null\") (subpath \"/usr/lib\") (subpath \"/System\") (literal \"{program}\"))\n" + )); + p.push_str("(allow sysctl-read)\n"); + for r in reads { + p.push_str(&format!("(allow file-read* (subpath \"{r}\"))\n")); + } + for w in writes { + p.push_str(&format!("(allow file-read* file-write* (subpath \"{w}\"))\n")); + } + p +} + +/// Whether Seatbelt confines a program here, shown rather than assumed: under +/// a policy that grants nothing, a control run must succeed, and a read +/// outside the policy, a write, a subprocess and (where `nc` exists) a +/// connection to a listener this probe opened must each fail. +#[cfg(target_os = "macos")] +fn probe() -> Option { + use std::process::{Command, Stdio}; + if !Path::new(SANDBOX_EXEC).is_file() { + return None; + } + let version = Command::new("/usr/bin/sw_vers").arg("-productVersion").output().ok()?; + let version = String::from_utf8(version.stdout).ok()?.trim().to_string(); + if version.is_empty() { + return None; + } + let dir = std::env::temp_dir().join(format!("sandbox-exec-os-probe-{}", std::process::id())); + std::fs::create_dir_all(&dir).ok()?; + let secret = dir.join("secret"); + std::fs::write(&secret, b"not granted").ok()?; + let written = dir.join("written"); + let run = |program: &str, args: &[&str]| -> Option { + let policy = seatbelt_policy(&resolve_program(program)?, &[], &[]); + let status = Command::new(SANDBOX_EXEC) + .arg("-p") + .arg(&policy) + .arg(program) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .ok()?; + Some(status.success()) + }; + let secret_s = secret.to_str()?; + let written_s = written.to_str()?; + let holds = run("/bin/cat", &["/dev/null"]) == Some(true) + && run("/bin/cat", &[secret_s]) == Some(false) + && run("/usr/bin/touch", &[written_s]) == Some(false) + && !written.exists() + && run("/bin/sh", &["-c", "/usr/bin/true; /usr/bin/true"]) == Some(false) + && network_denied(&run); + let _ = std::fs::remove_dir_all(&dir); + holds.then(|| Confinement { runtime: format!("seatbelt/{version}") }) +} + +/// Where `nc` exists: a connection from inside the policy to a listener on +/// this host's loopback must fail, and the listener must see nothing. +#[cfg(target_os = "macos")] +fn network_denied(run: &dyn Fn(&str, &[&str]) -> Option) -> bool { + use std::net::TcpListener; + if !Path::new("/usr/bin/nc").is_file() { + return true; + } + let Ok(listener) = TcpListener::bind("127.0.0.1:0") else { return false }; + let Ok(addr) = listener.local_addr() else { return false }; + if listener.set_nonblocking(true).is_err() { + return false; + } + let port = addr.port().to_string(); + let refused = run("/usr/bin/nc", &["-z", "-G", "2", "127.0.0.1", &port]) == Some(false); + refused && listener.accept().is_err() +} + +/// No backend off macOS: the executor is S0 (see the crate docs). +#[cfg(not(target_os = "macos"))] +fn probe() -> Option { + None +} + +#[cfg(test)] +mod tests { + use super::*; + use sandbox_capability::{BrowserGrant, ComputeGrant, DisplayGrant, HostGrant, NetworkGrant, ProcessGrant}; + + /// Grants this executor can enforce: CPU, the clock and randomness, and + /// the given storage paths. + fn enforceable(read: Vec, write: Vec) -> Grants { + let mut g = Grants { + compute: ComputeGrant::cpu_only(5_000_000, 200, 16 * 1024 * 1024), + host: HostGrant { logging: true, clock: true, random: true, inference: false }, + ..Grants::default() + }; + g.storage.read = read; + g.storage.write = write; + g + } + + fn executor() -> OsExecutor { + OsExecutor::new("/bin/cat", vec![], std::env::temp_dir()) + } + + /// Every grant the executor cannot enforce is refused, by name. + #[test] + fn grants_it_cannot_enforce_are_refused() { + let ok = enforceable(vec![], vec![]); + assert!(executor().policy(&ok).is_ok()); + let cases: Vec<(Grants, Unenforceable)> = vec![ + ( + Grants { network: NetworkGrant { outbound: vec!["example:443".into()], ..ok.network.clone() }, ..ok.clone() }, + Unenforceable::Network, + ), + ( + Grants { process: ProcessGrant { exec: true, ..ok.process.clone() }, ..ok.clone() }, + Unenforceable::Process, + ), + (Grants { browser: BrowserGrant { enabled: true, ..ok.browser.clone() }, ..ok.clone() }, Unenforceable::Browser), + (Grants { display: DisplayGrant { enabled: true, ..ok.display }, ..ok.clone() }, Unenforceable::Display), + (Grants { host: HostGrant { clock: false, ..ok.host }, ..ok.clone() }, Unenforceable::HostCall("clock")), + (Grants { host: HostGrant { random: false, ..ok.host }, ..ok.clone() }, Unenforceable::HostCall("random")), + (enforceable(vec!["relative/path".into()], vec![]), Unenforceable::Path("relative/path".into())), + (enforceable(vec![], vec!["/tmp/a\"b".into()]), Unenforceable::Path("/tmp/a\"b".into())), + ]; + for (grants, want) in cases { + assert_eq!(executor().policy(&grants).err(), Some(want)); + } + let mut gpu = ok.clone(); + gpu.compute.devices.push(DeviceClass::Gpu); + assert_eq!(executor().policy(&gpu).err(), Some(Unenforceable::Device(DeviceClass::Gpu))); + assert!(matches!( + OsExecutor::new("no-such-program-anywhere", vec![], "/").policy(&ok), + Err(Unenforceable::Program(_)) + )); + } + + /// The policy denies by default and then names exactly the program and + /// the granted paths, resolved; it never allows the network or a fork. + #[test] + fn the_policy_names_exactly_what_was_granted() { + let tmp = std::env::temp_dir().canonicalize().unwrap(); + let policy = executor() + .policy(&enforceable(vec![tmp.to_str().unwrap().into()], vec![tmp.join("out").to_str().unwrap().into()])) + .unwrap(); + assert!(policy.starts_with("(version 1)\n(deny default)\n"), "{policy}"); + assert!(policy.contains(&format!("(allow file-read* (subpath \"{}\"))", tmp.display())), "{policy}"); + assert!(policy.contains(&format!("(allow file-read* file-write* (subpath \"{}\"))", tmp.join("out").display())), "{policy}"); + assert!(policy.contains("(allow process-exec (literal \""), "{policy}"); + for never in ["network", "process-fork", "mach-lookup"] { + assert!(!policy.contains(never), "{never} in {policy}"); + } + } + + /// The rung matches the probe: S1 with a confinement here, S0 with no + /// mechanism where there is none. A host that is expected to confine + /// (`SANDBOX_EXEC_OS_EXPECT_S1`, set by the macOS CI job) fails here when + /// the probe does not hold, rather than passing on the S0 branch. + #[test] + fn the_rung_is_the_probes() { + if std::env::var_os("SANDBOX_EXEC_OS_EXPECT_S1").is_some() { + assert!(OsExecutor::confinement().is_some(), "this host is expected to confine, and the probe did not hold"); + } + let e = executor(); + match OsExecutor::confinement() { + Some(c) => { + eprintln!("sandbox-exec-os: confinement {} -> S1", c.runtime); + assert_eq!(e.tier(), Tier::S1); + assert!(c.runtime.starts_with("seatbelt/"), "{c:?}"); + } + None => { + eprintln!("sandbox-exec-os: no confinement on this host -> S0"); + assert_eq!(e.tier(), Tier::S0); + assert_eq!(e.mechanism(), None); + } + } + } +} diff --git a/sandbox/sandbox-rs/crates/sandbox-exec-os/tests/confined.rs b/sandbox/sandbox-rs/crates/sandbox-exec-os/tests/confined.rs new file mode 100644 index 000000000..db704cb52 --- /dev/null +++ b/sandbox/sandbox-rs/crates/sandbox-exec-os/tests/confined.rs @@ -0,0 +1,134 @@ +//! The executor end to end on this host: what it lets through, what it +//! denies, the mechanism it commits to, and the rung `Host::run_and_seal` +//! seals. Where the probe finds no confinement (any host but macOS, or a +//! macOS host whose sandbox did not hold), the same tests assert the S0 path +//! instead, so neither branch passes by skipping. + +use ed25519_dalek::SigningKey; +use sandbox_attest::{Mechanism, Outcome}; +use sandbox_capability::{ComputeGrant, Grants, HostGrant}; +use sandbox_exec_os::OsExecutor; +use sandbox_runtime::{Executor, Host, RunError, TierRequest, Unit}; +use sandbox_schema::Tier; +use std::path::{Path, PathBuf}; + +/// A scratch directory of its own for each test. +fn scratch(name: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("sandbox-exec-os-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d.canonicalize().unwrap() +} + +fn grants(read: &Path, write: &Path) -> Grants { + let mut g = Grants { + compute: ComputeGrant::cpu_only(5_000_000, 200, 16 * 1024 * 1024), + host: HostGrant { logging: true, clock: true, random: true, inference: false }, + ..Grants::default() + }; + g.storage.read = vec![read.to_str().unwrap().into()]; + g.storage.write = vec![write.to_str().unwrap().into()]; + g +} + +/// A granted read and a granted write go through; a file outside the grants +/// does not, and the run that tried says so. At S1 the mechanism commits to +/// the runtime and the very policy applied. +#[test] +fn it_reads_and_writes_what_was_granted_and_nothing_else() { + let dir = scratch("rw"); + let (input, output, secret) = (dir.join("in"), dir.join("out"), dir.join("secret")); + std::fs::create_dir_all(&input).unwrap(); + std::fs::create_dir_all(&output).unwrap(); + std::fs::create_dir_all(&secret).unwrap(); + std::fs::write(input.join("a.txt"), b"granted").unwrap(); + std::fs::write(secret.join("s.txt"), b"not granted").unwrap(); + let g = grants(&input, &output); + let unit = Unit::new(b"cp", &g); + + let copy = OsExecutor::new( + "/bin/cp", + vec![input.join("a.txt").to_str().unwrap().into(), output.join("b.txt").to_str().unwrap().into()], + &dir, + ); + let report = copy.run(&unit).expect("a run with enforceable grants"); + assert_eq!(report.outcome, Outcome::Completed); + assert_eq!(std::fs::read(output.join("b.txt")).unwrap(), b"granted"); + + let peek = OsExecutor::new("/bin/cat", vec![secret.join("s.txt").to_str().unwrap().into()], &dir); + let report = peek.run(&unit).expect("the run itself starts"); + match OsExecutor::confinement() { + Some(c) => { + eprintln!("sandbox-exec-os: {} denied a read outside the grants", c.runtime); + assert_eq!(report.outcome, Outcome::Failed, "a file outside the grants was read"); + let policy = peek.policy(&g).unwrap(); + let m = peek.mechanism().expect("a confined run commits to its mechanism"); + assert_eq!(m, Mechanism::commit(c.runtime.clone(), policy.as_bytes(), &[])); + assert!(m.runtime.starts_with("seatbelt/")); + } + None => { + eprintln!("sandbox-exec-os: no confinement here; the run is S0 and unconfined"); + assert_eq!(report.outcome, Outcome::Completed, "unconfined, the read succeeds"); + assert_eq!(peek.mechanism(), None, "no mechanism at S0"); + } + } + let _ = std::fs::remove_dir_all(&dir); +} + +/// The canonical host path: a trusted unit that writes storage needs exactly +/// S1. Where the confinement holds, the run is sealed at S1 with its +/// mechanism; where it does not, the host refuses to seal it at all. +#[test] +fn run_and_seal_seals_s1_only_where_the_confinement_holds() { + let dir = scratch("seal"); + let (input, output) = (dir.join("in"), dir.join("out")); + std::fs::create_dir_all(&input).unwrap(); + std::fs::create_dir_all(&output).unwrap(); + std::fs::write(input.join("a.txt"), b"granted").unwrap(); + let g = grants(&input, &output); + let unit = Unit::new(b"cp", &g); + let exec = OsExecutor::new( + "/bin/cp", + vec![input.join("a.txt").to_str().unwrap().into(), output.join("b.txt").to_str().unwrap().into()], + &dir, + ); + let supervisor = SigningKey::from_bytes(&[42u8; 32]); + let host = Host::new("sandbox-exec-os", "steward.os", SigningKey::from_bytes(&[41u8; 32]), supervisor.clone()); + let req = TierRequest { trusted: true, ..TierRequest::minimal(&g) }; + let sealed = host.run_and_seal(&exec, &unit, &req, 1, [0; 8], 1, 2); + match OsExecutor::confinement() { + Some(_) => { + let sealed = sealed.expect("sealed at S1"); + assert_eq!(sealed.attestation.tier, Tier::S1); + assert!(sealed.attestation.mechanism.is_some(), "an S1 run carries its mechanism"); + sealed.attestation.verify(&sealed.attestation_sig, &supervisor.verifying_key()).unwrap(); + } + None => { + assert!( + matches!(sealed, Err(RunError::TierMismatch { .. })), + "an S0 executor must not be sealed for a unit that needs S1" + ); + } + } + let _ = std::fs::remove_dir_all(&dir); +} + +/// Where the confinement holds, a unit whose grants it cannot enforce is +/// refused before anything runs, rather than run with a capability its grant +/// denies. +#[test] +fn a_unit_it_cannot_confine_is_refused_before_it_runs() { + if OsExecutor::confinement().is_none() { + eprintln!("sandbox-exec-os: no confinement here; nothing to refuse"); + return; + } + let dir = scratch("refuse"); + let marker = dir.join("ran"); + let mut g = grants(&dir, &dir); + g.host.clock = false; + let unit = Unit::new(b"touch", &g); + let exec = OsExecutor::new("/usr/bin/touch", vec![marker.to_str().unwrap().into()], &dir); + assert!(exec.run(&unit).is_err(), "a grant withholding the clock cannot be enforced natively"); + assert!(!marker.exists(), "the refused unit ran"); + let _ = std::fs::remove_dir_all(&dir); +} -- 2.54.0 (Apple Git-157)