vetto 0.2.13

Daemon-less sandbox + security layer for AI coding agents (Landlock/Seatbelt, TUI statusline, post-session audit reports)
Documentation
//! Seatbelt native C API (`sandbox_init_with_parameters`) and SBPL profile generation.
//!
//! SBPL semantics: `(deny default)` first, explicit allows for the policy
//! roots, and trailing `(deny ...)` rules carve secrets out of allowed trees
//! (last matching rule wins).
//!
//! Dynamic binding via `dlsym` against `libsandbox.1.dylib` / `libSystem.dylib`
//! eliminates the deprecated `/usr/bin/sandbox-exec` subprocess and disk files.

use std::ffi::CString;

use crate::config::NetMode;
use crate::policy::Policy;

/// Generate in-memory SBPL profile template and parameterized key-value pairs.
pub fn generate_sbpl_template_and_params(
    policy: &Policy,
    net: &NetMode,
) -> (String, Vec<(String, String)>) {
    let mut sb = String::with_capacity(2048);
    let mut params = Vec::new();

    // macOS tier model, set by the bisect matrix in tests/integration/
    // macos_bisect.rs: multi-clause (deny default) profiles with fragmented
    // file-read allowlists kill the exec'd agent with a silent SIGABRT on
    // current macOS, while single-clause profiles run fine. The macOS tier
    // therefore enforces WRITE isolation, network off and secret denies, and
    // keeps reads broad — documented in the README platform table. Reads of
    // display_only_deny secrets stay hard-denied by the trailing rules.
    sb.push_str("(version 1)\n(deny default)\n");
    sb.push_str("(allow process-exec)\n(allow process-fork)\n");
    sb.push_str("(allow sysctl-read)\n");
    sb.push_str("(allow mach-lookup)\n");
    sb.push_str("(allow file-read* (subpath \"/\"))\n");

    // Write roots with parameterization. Read stays broad, so no read roots
    // here; secrets are carved back out by the trailing deny rules below.
    for (i, p) in policy.allow_write.iter().enumerate() {
        let key = format!("ALLOW_WRITE_DIR_{i}");
        let val = p.display().to_string();
        sb.push_str(&format!(
            "(allow file-write* (subpath (param \"{key}\")))\n"
        ));
        params.push((key, val));
    }

    // Trailing deny rules for secret carving. SBPL is last-match-wins, so
    // these override the broad read allow above.
    for (i, d) in policy.deny_resolved.iter().enumerate() {
        let key = format!("DENY_PATH_{i}");
        let val = d.path.display().to_string();
        sb.push_str(&format!("(deny file-read* (subpath (param \"{key}\")))\n"));
        sb.push_str(&format!("(deny file-write* (subpath (param \"{key}\")))\n"));
        params.push((key, val));
    }

    // net=off: no IP traffic. The blanket network* denial requires the
    // unix-socket outbound exception — exec-time LaunchConstraints checks and
    // libSystem init perform local XPC over unix sockets, and without it the
    // process either fails execve with EPERM or aborts silently after exec.
    match net {
        NetMode::Off | NetMode::Allowlist(_) | NetMode::Strict(_) | NetMode::Ask => {
            sb.push_str("(deny network*)\n");
            sb.push_str("(allow network-outbound (remote unix-socket))\n");
        }
    }

    sb.push_str("\n; generated by vetto native seatbelt\n");
    (sb, params)
}

/// Generate monolithic SBPL profile string with inlined paths (for diagnostic / legacy inspection).
pub fn generate(policy: &Policy, net: &NetMode) -> String {
    let (template, params) = generate_sbpl_template_and_params(policy, net);
    let mut inlined = template;
    for (k, v) in params {
        let placeholder = format!("(param \"{k}\")");
        let escaped_val = format!("\"{}\"", sb_escape(&v));
        inlined = inlined.replace(&placeholder, &escaped_val);
    }
    inlined
}

fn sb_escape(s: &str) -> String {
    s.replace('\\', "\\\\").replace('"', "\\\"")
}

/// Probe whether native Seatbelt C API is available.
pub fn is_native_seatbelt_available() -> bool {
    #[cfg(target_os = "macos")]
    {
        let lib = unsafe {
            libc::dlopen(
                b"libsandbox.1.dylib\0".as_ptr().cast(),
                libc::RTLD_LAZY | libc::RTLD_LOCAL,
            )
        };
        if lib.is_null() {
            return false;
        }
        let sym = unsafe { libc::dlsym(lib, b"sandbox_init_with_parameters\0".as_ptr().cast()) };
        !sym.is_null()
    }
    #[cfg(not(target_os = "macos"))]
    {
        false
    }
}

/// Apply in-memory Seatbelt sandbox directly to the current process via C API.
/// Must be called post-fork before execve in the child process.
pub fn apply_seatbelt(policy: &Policy, net: &NetMode) -> Result<(), String> {
    let (template, params) = generate_sbpl_template_and_params(policy, net);
    apply_seatbelt_raw(&template, &params)
}

/// Apply raw SBPL profile and parameters to the calling process.
pub fn apply_seatbelt_raw(profile: &str, params: &[(String, String)]) -> Result<(), String> {
    #[cfg(target_os = "macos")]
    {
        let lib = unsafe {
            libc::dlopen(
                b"libsandbox.1.dylib\0".as_ptr().cast(),
                libc::RTLD_LAZY | libc::RTLD_LOCAL,
            )
        };
        if lib.is_null() {
            return Err("dlopen libsandbox.1.dylib failed".to_string());
        }

        let init_sym =
            unsafe { libc::dlsym(lib, b"sandbox_init_with_parameters\0".as_ptr().cast()) };
        let free_sym = unsafe { libc::dlsym(lib, b"sandbox_free_error\0".as_ptr().cast()) };

        if init_sym.is_null() {
            return Err("dlsym sandbox_init_with_parameters failed".to_string());
        }

        type SandboxInitWithParameters = unsafe extern "C" fn(
            profile: *const libc::c_char,
            flags: u64,
            parameters: *const *const libc::c_char,
            errorbuf: *mut *mut libc::c_char,
        ) -> libc::c_int;
        type SandboxFreeError = unsafe extern "C" fn(errorbuf: *mut libc::c_char);

        let sandbox_init: SandboxInitWithParameters = unsafe {
            std::mem::transmute::<*mut libc::c_void, SandboxInitWithParameters>(init_sym)
        };
        let sandbox_free: Option<SandboxFreeError> = if !free_sym.is_null() {
            Some(unsafe { std::mem::transmute::<*mut libc::c_void, SandboxFreeError>(free_sym) })
        } else {
            None
        };

        let profile_c = CString::new(profile).map_err(|e| format!("profile CString: {e}"))?;
        let mut cstrings: Vec<CString> = Vec::with_capacity(params.len() * 2);
        for (k, v) in params {
            cstrings.push(CString::new(k.as_str()).map_err(|e| format!("param key CString: {e}"))?);
            cstrings.push(CString::new(v.as_str()).map_err(|e| format!("param val CString: {e}"))?);
        }

        let mut param_ptrs: Vec<*const libc::c_char> =
            cstrings.iter().map(|s| s.as_ptr()).collect();
        param_ptrs.push(std::ptr::null());

        let mut errorbuf: *mut libc::c_char = std::ptr::null_mut();
        let ret =
            unsafe { sandbox_init(profile_c.as_ptr(), 0, param_ptrs.as_ptr(), &mut errorbuf) };

        if ret != 0 {
            let err_msg = if !errorbuf.is_null() {
                let msg = unsafe { std::ffi::CStr::from_ptr(errorbuf) }
                    .to_string_lossy()
                    .into_owned();
                if let Some(free_fn) = sandbox_free {
                    unsafe { free_fn(errorbuf) };
                }
                msg
            } else {
                format!("sandbox_init_with_parameters failed with code {ret}")
            };
            return Err(err_msg);
        }

        Ok(())
    }
    #[cfg(not(target_os = "macos"))]
    {
        let _ = (profile, params);
        Err("Seatbelt is only available on macOS".to_string())
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SbplFragmentStatus {
    Broken,
    Ok,
}

impl SbplFragmentStatus {
    pub fn as_str(&self) -> &'static str {
        match self {
            Self::Broken => "broken (Apple regression)",
            Self::Ok => "ok",
        }
    }

    pub fn parse_from_output(success: bool, output: &str) -> Self {
        if success && output.trim() == "sbpl-probe-ok" {
            Self::Ok
        } else {
            Self::Broken
        }
    }
}

/// Dynamic microprobe that tests whether fragmented SBPL read-isolation
/// profiles trigger Apple's dyld/libSystem SIGABRT regression on this macOS build.
pub fn probe_sbpl_read_fragment() -> SbplFragmentStatus {
    #[cfg(target_os = "macos")]
    {
        let temp_dir = std::env::temp_dir();
        let probe_file = temp_dir.join(format!("vetto-sbpl-probe-{}.txt", std::process::id()));
        if std::fs::write(&probe_file, "sbpl-probe-ok").is_err() {
            return SbplFragmentStatus::Broken;
        }

        let probe_path_str = probe_file.to_string_lossy();
        let profile = format!(
            "(version 1)\n(deny default)\n(allow process-exec)\n(allow process-fork)\n(allow sysctl-read)\n(allow mach-lookup)\n(allow file-read* (literal \"{probe_path_str}\"))\n(allow file-read* (subpath \"/bin\"))\n(allow file-read* (subpath \"/usr\"))\n(allow file-read* (subpath \"/lib\"))\n(allow file-read* (subpath \"/System\"))\n"
        );

        let output = std::process::Command::new("/usr/bin/sandbox-exec")
            .arg("-p")
            .arg(profile)
            .arg("/bin/cat")
            .arg(&probe_file)
            .output();

        let _ = std::fs::remove_file(&probe_file);

        match output {
            Ok(out) => {
                let stdout = String::from_utf8_lossy(&out.stdout);
                SbplFragmentStatus::parse_from_output(out.status.success(), &stdout)
            }
            Err(_) => SbplFragmentStatus::Broken,
        }
    }
    #[cfg(not(target_os = "macos"))]
    {
        SbplFragmentStatus::Broken
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::PathBuf;

    #[test]
    fn sbpl_fragment_status_parsing() {
        assert_eq!(
            SbplFragmentStatus::parse_from_output(true, "sbpl-probe-ok\n"),
            SbplFragmentStatus::Ok
        );
        assert_eq!(
            SbplFragmentStatus::parse_from_output(false, "sbpl-probe-ok\n"),
            SbplFragmentStatus::Broken
        );
        assert_eq!(
            SbplFragmentStatus::parse_from_output(true, "something else"),
            SbplFragmentStatus::Broken
        );
        assert_eq!(
            SbplFragmentStatus::Broken.as_str(),
            "broken (Apple regression)"
        );
        assert_eq!(SbplFragmentStatus::Ok.as_str(), "ok");
    }

    #[test]
    fn template_generation_contains_expected_params() {
        let mut policy = Policy::default();
        policy.allow_read.push(PathBuf::from("/test/read"));
        policy.allow_write.push(PathBuf::from("/test/write"));
        policy.deny_resolved.push(crate::policy::DenyEntry {
            path: PathBuf::from("/test/secret"),
            is_dir: false,
        });

        let (template, params) = generate_sbpl_template_and_params(&policy, &NetMode::Off);
        // Write-isolation model: reads are broad, write roots and secret
        // denies are the parameterized clauses.
        assert!(template.contains("(param \"ALLOW_WRITE_DIR_0\")"));
        assert!(template.contains("(param \"DENY_PATH_0\")"));
        assert!(!template.contains("ALLOW_READ_DIR"));
        assert!(template.contains("(deny network*)"));
        assert!(template.contains("(allow network-outbound (remote unix-socket))"));

        assert_eq!(params.len(), 2);
        assert_eq!(
            params[0],
            ("ALLOW_WRITE_DIR_0".to_string(), "/test/write".to_string())
        );
        assert_eq!(
            params[1],
            ("DENY_PATH_0".to_string(), "/test/secret".to_string())
        );

        let inlined = generate(&policy, &NetMode::Off);
        assert!(inlined.contains("\"/test/write\""));
        assert!(inlined.contains("\"/test/secret\""));
    }
}