envseal 0.3.3

Write-only secret vault with process-level access control — post-agent secret management
Documentation
//! Windows sandbox backend — Job Object based process containment.
//!
//! macOS and Linux apply isolation in the child's `pre_exec` closure (between
//! `fork` and `exec`). Windows has no `pre_exec`; instead the parent assigns
//! the child to a Job Object after the child is spawned, and the Job Object's
//! limits propagate to the child for the rest of its lifetime.
//!
//! # Tier mapping
//!
//! `None` is a no-op. `Hardened` sets `KILL_ON_JOB_CLOSE` (child dies if
//! the supervisor exits), `DIE_ON_UNHANDLED_EXCEPTION` (silent crashes
//! are killed), and leaves `BREAKAWAY_OK` clear so the child cannot spawn
//! descendants outside the job. `Lockdown` adds `ACTIVE_PROCESS = 1`
//! (the supervised child is the only process allowed in the job — any
//! attempt to spawn another inside the job is denied with
//! `ERROR_NOT_ENOUGH_QUOTA`) and all `JOB_OBJECT_UILIMIT_*` restrictions
//! so the child cannot read other processes' handles, manipulate the
//! global atom table, change desktop/display settings, or interact with
//! the windowing subsystem of other sessions.
//!
//! # Race window — closed via `CREATE_SUSPENDED` + `NtResumeProcess`
//!
//! `std::process::Command::spawn` calls `CreateProcessW` and returns a
//! handle to a process whose main thread is already running. Between
//! that point and our subsequent `AssignProcessToJobObject` call there
//! is a multi-millisecond window where the child runs unconstrained.
//! A child that `CreateProcessW`s a grandchild during that window
//! produces a grandchild that is **not** in the job, escaping
//! `ACTIVE_PROCESS_LIMIT` and `KILL_ON_JOB_CLOSE`.
//!
//! [`spawn_in_job`] eliminates the window. The flow is:
//!
//! 1. Caller-supplied `Command` is augmented with the `CREATE_SUSPENDED`
//!    creation flag via `std::os::windows::process::CommandExt`.
//!    `CreateProcessW` returns with the child created but its main
//!    thread suspended — not a single user instruction has run.
//! 2. The Job Object is created and configured.
//! 3. `AssignProcessToJobObject` slots the still-frozen child into the
//!    job. From this point onward, any `CreateProcessW` issued by the
//!    child (or its descendants) inherits the job assignment.
//! 4. `NtResumeProcess` (undocumented but stable Win32-since-XP API
//!    exported by `ntdll.dll`) resumes all threads of the child. The
//!    child's `main` runs for the first time, already inside the
//!    job. There is no instant where the child is alive and not
//!    constrained.
//!
//! The legacy [`apply_to_process`] post-spawn variant is kept as a
//! fallback for `tier == SandboxTier::None` callers and for
//! diagnostic test code.
//!
//! # Tracking
//!
//! The `OwnedJob` returned by [`apply_to_process`] is dropped by the
//! supervisor *after* it has finished `wait`-ing for the child. Dropping
//! the handle terminates any process still in the job (because of
//! `KILL_ON_JOB_CLOSE`) — a belt-and-suspenders against a child that
//! `Detach`-style escapes a `wait`.

#![cfg(target_os = "windows")]

use std::os::windows::io::{AsRawHandle, RawHandle};
use std::os::windows::process::CommandExt;
use std::process::{Child, Command};
use std::ptr;

use windows_sys::Win32::Foundation::{CloseHandle, GetLastError, ERROR_SUCCESS, FALSE, HANDLE};
use windows_sys::Win32::System::JobObjects::{
    AssignProcessToJobObject, CreateJobObjectW, JobObjectBasicLimitInformation,
    JobObjectBasicUIRestrictions, SetInformationJobObject, JOBOBJECT_BASIC_LIMIT_INFORMATION,
    JOBOBJECT_BASIC_UI_RESTRICTIONS, JOB_OBJECT_LIMIT_ACTIVE_PROCESS,
    JOB_OBJECT_LIMIT_DIE_ON_UNHANDLED_EXCEPTION, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
    JOB_OBJECT_UILIMIT_DESKTOP, JOB_OBJECT_UILIMIT_DISPLAYSETTINGS, JOB_OBJECT_UILIMIT_EXITWINDOWS,
    JOB_OBJECT_UILIMIT_GLOBALATOMS, JOB_OBJECT_UILIMIT_HANDLES, JOB_OBJECT_UILIMIT_READCLIPBOARD,
    JOB_OBJECT_UILIMIT_SYSTEMPARAMETERS, JOB_OBJECT_UILIMIT_WRITECLIPBOARD,
};
use windows_sys::Win32::System::Threading::CREATE_SUSPENDED;

use super::SandboxTier;

// `NtResumeProcess` — exported by `ntdll.dll`, present since Windows XP,
// not in the Win32 SDK so we declare it manually. Resumes every thread
// of `process_handle` in a single syscall. Returns NTSTATUS (0 ==
// `STATUS_SUCCESS`).
#[link(name = "ntdll")]
extern "system" {
    fn NtResumeProcess(process_handle: HANDLE) -> i32;
}

/// Owned Job Object handle. Closed on drop, which (because the job has
/// `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`) terminates any still-running
/// process inside it.
pub struct OwnedJob {
    handle: HANDLE,
}

impl OwnedJob {
    fn new() -> std::io::Result<Self> {
        let h = unsafe { CreateJobObjectW(ptr::null_mut(), ptr::null()) };
        if h.is_null() {
            return Err(last_os_error("CreateJobObjectW"));
        }
        Ok(Self { handle: h })
    }
}

impl Drop for OwnedJob {
    fn drop(&mut self) {
        if !self.handle.is_null() {
            unsafe {
                CloseHandle(self.handle);
            }
        }
    }
}

/// Apply the requested Job Object limits to `child` and return the owned
/// Job handle.
///
/// The caller MUST keep the returned [`OwnedJob`] alive at least until the
/// child has been `wait`-ed on; dropping the job terminates any still-live
/// process inside it.
///
/// # Errors
///
/// Returns the underlying `io::Error` on failure of any of:
/// - `CreateJobObjectW`
/// - `SetInformationJobObject`
/// - `AssignProcessToJobObject`
pub fn apply_to_process(tier: SandboxTier, child: &Child) -> std::io::Result<Option<OwnedJob>> {
    if matches!(tier, SandboxTier::None) {
        return Ok(None);
    }

    let job = OwnedJob::new()?;
    set_basic_limits(job.handle, tier)?;
    if matches!(tier, SandboxTier::Lockdown) {
        set_ui_restrictions(job.handle)?;
    }

    let process_handle: RawHandle = child.as_raw_handle();
    let assigned = unsafe { AssignProcessToJobObject(job.handle, process_handle as HANDLE) };
    if assigned == FALSE {
        return Err(last_os_error("AssignProcessToJobObject"));
    }

    Ok(Some(job))
}

fn set_basic_limits(job: HANDLE, tier: SandboxTier) -> std::io::Result<()> {
    let mut limit_flags =
        JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_DIE_ON_UNHANDLED_EXCEPTION;
    let mut active_process_limit = 0u32;
    if matches!(tier, SandboxTier::Lockdown) {
        limit_flags |= JOB_OBJECT_LIMIT_ACTIVE_PROCESS;
        active_process_limit = 1;
    }

    let mut basic = JOBOBJECT_BASIC_LIMIT_INFORMATION {
        LimitFlags: limit_flags,
        ActiveProcessLimit: active_process_limit,
        ..unsafe { std::mem::zeroed() }
    };

    // SetInformationJobObject for `JobObjectBasicLimitInformation` takes a
    // `JOBOBJECT_BASIC_LIMIT_INFORMATION`. For the extended variant we'd
    // pass `JOBOBJECT_EXTENDED_LIMIT_INFORMATION`. We use the basic form
    // because we don't currently set any extended-only fields.
    // SAFETY: `JOBOBJECT_BASIC_LIMIT_INFORMATION` is a fixed-size struct of
    // ~64 bytes; the cast to `u32` cannot truncate.
    #[allow(clippy::cast_possible_truncation)]
    let size = std::mem::size_of::<JOBOBJECT_BASIC_LIMIT_INFORMATION>() as u32;
    let rc = unsafe {
        SetInformationJobObject(
            job,
            JobObjectBasicLimitInformation,
            std::ptr::addr_of_mut!(basic).cast(),
            size,
        )
    };
    if rc == FALSE {
        return Err(last_os_error(
            "SetInformationJobObject(BasicLimitInformation)",
        ));
    }
    Ok(())
}

fn set_ui_restrictions(job: HANDLE) -> std::io::Result<()> {
    let mut ui = JOBOBJECT_BASIC_UI_RESTRICTIONS {
        UIRestrictionsClass: JOB_OBJECT_UILIMIT_DESKTOP
            | JOB_OBJECT_UILIMIT_DISPLAYSETTINGS
            | JOB_OBJECT_UILIMIT_EXITWINDOWS
            | JOB_OBJECT_UILIMIT_GLOBALATOMS
            | JOB_OBJECT_UILIMIT_HANDLES
            | JOB_OBJECT_UILIMIT_READCLIPBOARD
            | JOB_OBJECT_UILIMIT_SYSTEMPARAMETERS
            | JOB_OBJECT_UILIMIT_WRITECLIPBOARD,
    };

    // SAFETY: `JOBOBJECT_BASIC_UI_RESTRICTIONS` is a 4-byte struct;
    // the cast to `u32` cannot truncate.
    #[allow(clippy::cast_possible_truncation)]
    let size = std::mem::size_of::<JOBOBJECT_BASIC_UI_RESTRICTIONS>() as u32;
    let rc = unsafe {
        SetInformationJobObject(
            job,
            JobObjectBasicUIRestrictions,
            std::ptr::addr_of_mut!(ui).cast(),
            size,
        )
    };
    if rc == FALSE {
        return Err(last_os_error(
            "SetInformationJobObject(BasicUIRestrictions)",
        ));
    }
    Ok(())
}

fn last_os_error(label: &str) -> std::io::Error {
    let err = unsafe { GetLastError() };
    if err == ERROR_SUCCESS {
        return std::io::Error::other(format!("{label} failed with no error code"));
    }
    let raw = i32::try_from(err).unwrap_or(i32::MAX);
    std::io::Error::from_raw_os_error(raw)
}

/// Whether the Windows sandbox primitive (`CreateJobObjectW`) is reachable
/// on this build. Always `true` on Windows. Used by [`super::OsCapabilities`]
/// for diagnostic reporting.
#[must_use]
pub fn available() -> bool {
    true
}

/// Spawn `cmd` directly into a Job Object, with no race window between
/// process creation and job assignment.
///
/// The implementation uses `CREATE_SUSPENDED` so the child's main thread
/// is created but never executes; we then `AssignProcessToJobObject` and
/// finally `NtResumeProcess`. From the child's perspective, the very
/// first instruction it runs already sees itself in the job — there is
/// no unconstrained interval.
///
/// For [`SandboxTier::None`] this falls back to a plain `cmd.spawn()`
/// since there is no job to bind to.
///
/// # Errors
///
/// - `cmd.spawn` errors propagate as `io::Error`.
/// - Failure of any of `CreateJobObjectW`, `SetInformationJobObject`,
///   `AssignProcessToJobObject`, or `NtResumeProcess` produces an
///   `io::Error` from `GetLastError` / NTSTATUS. On such failures the
///   child is terminated to avoid leaving an unconstrained process
///   behind.
pub fn spawn_in_job(
    cmd: &mut Command,
    tier: SandboxTier,
) -> std::io::Result<(Child, Option<OwnedJob>)> {
    if matches!(tier, SandboxTier::None) {
        let child = cmd.spawn()?;
        return Ok((child, None));
    }

    // Stamp CREATE_SUSPENDED on top of any flags the caller already set.
    // `CommandExt::creation_flags` overwrites any existing value, so we
    // don't have to read-modify-write.
    cmd.creation_flags(CREATE_SUSPENDED);

    let mut child = cmd.spawn()?;

    // From here on, any error must terminate the child — leaving a
    // suspended process around would be a resource leak; leaving a
    // running unconstrained one would be a security regression.
    let job = match prepare_job(tier) {
        Ok(j) => j,
        Err(e) => {
            terminate_and_release(&mut child);
            return Err(e);
        }
    };

    let process_handle = child.as_raw_handle() as HANDLE;
    let assigned = unsafe { AssignProcessToJobObject(job.handle, process_handle) };
    if assigned == FALSE {
        let err = last_os_error("AssignProcessToJobObject");
        terminate_and_release(&mut child);
        return Err(err);
    }

    // SAFETY: `process_handle` is valid (we hold `child` until after this
    // call) and `NtResumeProcess` only consumes it; ownership of the
    // handle remains with `child`.
    let status = unsafe { NtResumeProcess(process_handle) };
    if status < 0 {
        terminate_and_release(&mut child);
        return Err(std::io::Error::other(format!(
            "NtResumeProcess failed with NTSTATUS 0x{status:x}"
        )));
    }

    Ok((child, Some(job)))
}

/// Build the Job Object and its limits, ready to receive a process via
/// [`AssignProcessToJobObject`].
fn prepare_job(tier: SandboxTier) -> std::io::Result<OwnedJob> {
    let job = OwnedJob::new()?;
    set_basic_limits(job.handle, tier)?;
    if matches!(tier, SandboxTier::Lockdown) {
        set_ui_restrictions(job.handle)?;
    }
    Ok(job)
}

/// Last-resort cleanup when the spawn-into-job sequence aborts midway:
/// kill the still-suspended (or barely-running) child so it cannot
/// continue without our containment. Best-effort; both `kill` and
/// `wait` failures are ignored because we are already on an error
/// path and the caller will propagate the original error.
fn terminate_and_release(child: &mut Child) {
    let _ = child.kill();
    let _ = child.wait();
}