envseal 0.3.8

Write-only secret vault with process-level access control — post-agent secret management
Documentation
//! Process sandbox — OS-level isolation expressed as an abstract tier.
//!
//! The public type is [`SandboxTier`]: an abstract isolation intent
//! ([`None`](SandboxTier::None) / [`Hardened`](SandboxTier::Hardened) /
//! [`Lockdown`](SandboxTier::Lockdown)). Each platform's backend translates
//! that intent into native syscalls. Callers never name platform primitives
//! (namespaces, sandbox profiles, Job Objects) — only the tier they want.
//!
//! # Platform support
//!
//! - **Linux** — Hardened (PID + IPC namespaces) and Lockdown (PID + mount +
//!   network + IPC namespaces). The post-`unshare` private-mount setup runs
//!   from `crate::execution::sandbox_helper` because `mount(2)` is not
//!   async-signal-safe in `pre_exec`.
//! - **macOS** — Hardened (`sandbox_init` `no-internet` profile) and
//!   Lockdown (`pure-computation` profile).
//! - **Windows** — Hardened (Job Object with `KILL_ON_JOB_CLOSE` +
//!   `DIE_ON_UNHANDLED_EXCEPTION`) and Lockdown (Hardened plus
//!   `ACTIVE_PROCESS_LIMIT=1` and full UI restrictions).
//!
//! # Submodules
//!
//! - [`capabilities`] — Runtime OS capability detection.
//! - `linux` (Linux only) — Namespace isolation backend.
//! - `macos` (macOS only) — `sandbox_init` backend.
//! - `windows` (Windows only) — Job Object backend.

use serde::{Deserialize, Serialize};

/// Abstract isolation intent. Each platform backend translates this to its
/// native primitives.
///
/// Stronger tiers strictly include weaker tier protections.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum SandboxTier {
    /// No isolation requested. The child runs with only the always-on
    /// hardening (`env_clear`, `dumpable=0`, `no_new_privs`, `RLIMIT_CORE=0`).
    #[default]
    None,
    /// Process-level isolation. The child cannot see, signal, or attach to
    /// other processes; cannot escape to a parent process group.
    ///
    /// - **Linux**: PID + IPC namespaces (no mount, no network).
    /// - **macOS** *(planned)*: sandbox profile denying `process-info*`.
    /// - **Windows** *(planned)*: Job Object with breakaway disabled, low integrity.
    Hardened,
    /// Strongest available isolation. The child cannot reach the network,
    /// cannot write to the filesystem outside an ephemeral scratch space,
    /// and cannot communicate with the parent via shared memory.
    ///
    /// - **Linux**: PID + mount + network + IPC namespaces. The supervisor
    ///   routes the spawn through `crate::execution::sandbox_helper`
    ///   so private `tmpfs`-on-`/tmp` and `MS_PRIVATE` propagation are set
    ///   up inside the new mount namespace.
    /// - **macOS** *(planned)*: sandbox profile denying network and writes
    ///   outside `/tmp`.
    /// - **Windows** *(planned)*: Job Object + Restricted Token + WFP
    ///   filter blocking outbound network.
    Lockdown,
}

impl SandboxTier {
    /// Whether any isolation is requested (i.e. anything beyond the
    /// always-on parent hardening).
    #[must_use]
    pub fn any_isolation(self) -> bool {
        !matches!(self, SandboxTier::None)
    }

    /// Stable lowercase name (matches the serialized form).
    #[must_use]
    pub fn as_str(self) -> &'static str {
        match self {
            SandboxTier::None => "none",
            SandboxTier::Hardened => "hardened",
            SandboxTier::Lockdown => "lockdown",
        }
    }

    /// Parse a stable lowercase tier name (matches the serialized form).
    ///
    /// Accepts `"none"`, `"standard"` (alias for `"none"`), `"hardened"`, `"lockdown"`.
    ///
    /// # Errors
    ///
    /// Returns an `Err` containing the offending input when the tier name is unknown.
    pub fn parse(name: &str) -> Result<Self, String> {
        match name {
            "none" | "standard" => Ok(SandboxTier::None),
            "hardened" => Ok(SandboxTier::Hardened),
            "lockdown" => Ok(SandboxTier::Lockdown),
            other => Err(other.to_string()),
        }
    }
}

pub mod capabilities;
pub use capabilities::OsCapabilities;

#[cfg(target_os = "linux")]
pub mod linux;

#[cfg(target_os = "macos")]
pub mod macos;

#[cfg(target_os = "windows")]
pub mod windows;

#[cfg(target_os = "linux")]
pub use linux::{apply_sandbox, user_namespaces_available};

#[cfg(target_os = "macos")]
pub use macos::apply_sandbox;

/// Apply sandbox isolation in a pre-exec context.
///
/// Linux uses namespaces; macOS uses `sandbox_init`. On other platforms
/// (currently Windows) requesting any non-`None` tier is a hard error:
/// there is no fallback that would honor the intent, and silently spawning
/// an un-sandboxed child would lie to a caller that asked for confinement.
///
/// # Errors
///
/// Outside Linux/macOS: [`std::io::ErrorKind::Unsupported`] if the tier
/// requests any isolation. On Linux: namespace `unshare` errors. On
/// macOS: `sandbox_init` errors.
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
pub fn apply_sandbox(tier: SandboxTier) -> std::io::Result<()> {
    if tier.any_isolation() {
        return Err(std::io::Error::new(
            std::io::ErrorKind::Unsupported,
            format!(
                "sandbox tier '{}' is not yet implemented on this platform; \
                 refusing to run an un-sandboxed child when isolation was requested",
                tier.as_str()
            ),
        ));
    }
    Ok(())
}

/// Whether unprivileged user namespaces are available.
///
/// Always `false` off Linux — namespaces don't exist on macOS/Windows.
#[cfg(not(target_os = "linux"))]
#[must_use]
pub fn user_namespaces_available() -> bool {
    false
}

#[cfg(test)]
mod tests {
    use super::SandboxTier;

    #[test]
    fn apply_sandbox_none_never_panics() {
        // apply_sandbox is designed for pre_exec contexts; on Linux it
        // calls unshare, on macOS sandbox_init, on Windows it returns
        // Ok(()) for None. We can safely call it here because None is
        // a no-op on every platform.
        assert!(super::apply_sandbox(SandboxTier::None).is_ok());
    }

    #[test]
    fn sandbox_tier_parse_roundtrip() {
        for tier in [
            SandboxTier::None,
            SandboxTier::Hardened,
            SandboxTier::Lockdown,
        ] {
            let parsed = SandboxTier::parse(tier.as_str()).unwrap();
            assert_eq!(parsed, tier);
        }
    }

    #[test]
    fn sandbox_tier_standard_alias_is_none() {
        assert_eq!(SandboxTier::parse("standard").unwrap(), SandboxTier::None);
    }

    #[test]
    fn sandbox_tier_any_isolation() {
        assert!(!SandboxTier::None.any_isolation());
        assert!(SandboxTier::Hardened.any_isolation());
        assert!(SandboxTier::Lockdown.any_isolation());
    }
}