roboticus-agent 0.11.3

Agent core with ReAct loop, policy engine, injection defense, memory system, and skill loader
Documentation
//! Windows process sandboxing via Job Objects.
//!
//! Provides resource-limited process confinement matching the intent of the
//! macOS sandbox-exec and Linux Landlock implementations. On Windows, full
//! filesystem path restriction requires AppContainer (complex ACL setup);
//! this module uses Job Objects for:
//!
//! - Memory limits (matching Linux RLIMIT_AS)
//! - Kill-on-close (child cannot outlive parent)
//! - Process count limits (no fork bombs)
//!
//! Filesystem confinement is best-effort — the mechanic health check reports
//! that Windows Job Objects do not provide path-level write restriction.

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

use tracing::{info, warn};
use windows::Win32::Foundation::{CloseHandle, HANDLE};
use windows::Win32::System::JobObjects::{
    AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_ACTIVE_PROCESS,
    JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, JOB_OBJECT_LIMIT_PROCESS_MEMORY,
    JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation,
    SetInformationJobObject,
};
use windows::Win32::System::Threading::{OpenProcess, PROCESS_ALL_ACCESS};

/// RAII wrapper for a Windows Job Object handle.
pub struct JobGuard {
    handle: HANDLE,
}

// SAFETY: Windows Job Object handles are kernel objects — they are not
// thread-local and can safely be transferred between threads. The handle
// is only used for `CloseHandle` on drop.
unsafe impl Send for JobGuard {}

impl Drop for JobGuard {
    fn drop(&mut self) {
        if !self.handle.is_invalid() {
            unsafe {
                let _ = CloseHandle(self.handle);
            }
        }
    }
}

/// Apply Job Object confinement to a spawned child process.
///
/// - `child_handle`: raw handle from `tokio::process::Child` (via `.as_raw_handle()`)
/// - `max_memory_bytes`: optional memory ceiling (like Linux RLIMIT_AS)
///
/// Returns a `JobGuard` that must be kept alive until the child exits.
/// Dropping it closes the job handle, which (with KILL_ON_JOB_CLOSE) terminates
/// the child if still running.
///
/// Open a process handle from a PID. Used because `tokio::process::Child`
/// doesn't implement `AsRawHandle` — we get the PID via `child.id()` and
/// open the handle via Win32 `OpenProcess`.
pub fn open_process_handle(pid: u32) -> Result<std::os::windows::io::RawHandle, String> {
    let handle = unsafe { OpenProcess(PROCESS_ALL_ACCESS, false, pid) }
        .map_err(|e| format!("OpenProcess({pid}) failed: {e}"))?;
    Ok(handle.0 as std::os::windows::io::RawHandle)
}

/// On failure, logs a warning and returns `Ok(None)` — graceful degradation
/// matching Landlock's behavior on unsupported kernels.
pub fn apply_job_confinement(
    child_handle: std::os::windows::io::RawHandle,
    max_memory_bytes: Option<u64>,
) -> Result<Option<JobGuard>, String> {
    let job = unsafe { CreateJobObjectW(None, None) }
        .map_err(|e| format!("CreateJobObjectW failed: {e}"))?;

    if job.is_invalid() {
        warn!("roboticus: warning: Windows Job Object creation returned invalid handle");
        return Ok(None);
    }

    // Configure limits
    let mut limit_info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
    limit_info.BasicLimitInformation.LimitFlags =
        JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_ACTIVE_PROCESS;
    limit_info.BasicLimitInformation.ActiveProcessLimit = 8;

    if let Some(max_bytes) = max_memory_bytes {
        limit_info.BasicLimitInformation.LimitFlags |= JOB_OBJECT_LIMIT_PROCESS_MEMORY;
        limit_info.ProcessMemoryLimit = max_bytes as usize;
    }

    unsafe {
        SetInformationJobObject(
            job,
            JobObjectExtendedLimitInformation,
            &limit_info as *const _ as *const std::ffi::c_void,
            std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
        )
    }
    .map_err(|e| format!("SetInformationJobObject failed: {e}"))?;

    // Assign child process to the job
    let child = HANDLE(child_handle as *mut std::ffi::c_void);
    unsafe { AssignProcessToJobObject(job, child) }
        .map_err(|e| format!("AssignProcessToJobObject failed: {e}"))?;

    info!("applied Windows Job Object confinement to script process");

    Ok(Some(JobGuard { handle: job }))
}

/// Log a warning about Windows filesystem confinement limitations.
/// Called when `script_fs_confinement` is enabled but we can only provide
/// resource limits, not path-level write restriction.
pub fn warn_fs_confinement_limited() {
    warn!(
        "roboticus: Windows Job Objects do not restrict filesystem paths. \
         script_fs_confinement provides memory/process limits only. \
         Full path restriction requires AppContainer (not yet implemented)."
    );
}

#[cfg(test)]
#[cfg(target_os = "windows")]
mod tests {
    use super::*;
    use std::os::windows::io::AsRawHandle;
    use std::process::Command;

    #[test]
    fn job_guard_is_created() {
        let child = Command::new("cmd")
            .args(["/C", "echo hello"])
            .spawn()
            .unwrap();

        let handle = child.as_raw_handle();
        let guard = apply_job_confinement(handle, Some(256 * 1024 * 1024));
        assert!(guard.is_ok());
        let guard = guard.unwrap();
        assert!(guard.is_some());
    }

    #[test]
    fn job_guard_with_no_memory_limit() {
        let child = Command::new("cmd")
            .args(["/C", "echo hello"])
            .spawn()
            .unwrap();

        let handle = child.as_raw_handle();
        let guard = apply_job_confinement(handle, None);
        assert!(guard.is_ok());
    }
}