vetto 0.3.7

Daemon-less sandbox + security layer for AI coding agents (Landlock/Seatbelt, TUI statusline, post-session audit reports)
Documentation
//! Concrete remediation commands and steps for missing sandbox primitives.

use std::path::{Path, PathBuf};

/// Honest guidance printed when a macOS Seatbelt/libsandbox denial smells
/// like TCC (Transparency, Consent, and Control): the terminal host lacks
/// Full Disk Access for a guarded user folder.
pub const MACOS_TCC_GUIDANCE: &str = "vetto: macOS TCC restriction: Terminal requires Full Disk Access to sandbox paths under ~/Documents or ~/Downloads. Grant access in System Settings > Privacy & Security > Full Disk Access, or run workloads from standard workspace paths (e.g. ~/projects).";

/// Top-level folders under `$HOME` guarded by macOS TCC.
const MACOS_TCC_GUARDED_FOLDERS: [&str; 3] = ["Documents", "Desktop", "Downloads"];

/// Returns true when `path` sits directly under a TCC-guarded user folder
/// (`~/Documents`, `~/Desktop`, `~/Downloads`). A leading `~` is expanded
/// against `$HOME`. Paths outside `$HOME` are never treated as TCC-guarded.
pub fn is_macos_tcc_protected_path(path: &Path) -> bool {
    let home = std::env::var_os("HOME").map(PathBuf::from);
    let expanded: PathBuf = match path.components().next() {
        Some(std::path::Component::Normal(first)) if first == "~" => match home.clone() {
            Some(h) => h.join(path.strip_prefix("~").unwrap_or(path)),
            None => return false,
        },
        _ => path.to_path_buf(),
    };
    let Some(home) = home else {
        return false;
    };
    let Ok(relative) = expanded.strip_prefix(&home) else {
        return false;
    };
    match relative.components().next() {
        Some(std::path::Component::Normal(top)) => MACOS_TCC_GUARDED_FOLDERS
            .iter()
            .any(|guarded| top == *guarded),
        _ => false,
    }
}

/// Returns [`MACOS_TCC_GUIDANCE`] when `io_error` is a permission failure
/// (`EPERM`/`EACCES`) for a TCC-guarded path, `None` otherwise. Pure and
/// total: never panics, never touches the filesystem.
pub fn macos_tcc_hint_for_error(path: &Path, io_error: &std::io::Error) -> Option<&'static str> {
    if !is_macos_tcc_protected_path(path) {
        return None;
    }
    if io_error.kind() == std::io::ErrorKind::PermissionDenied || io_error.raw_os_error() == Some(1)
    {
        Some(MACOS_TCC_GUIDANCE)
    } else {
        None
    }
}

/// Builds a [`DoctorFix`] pointing at TCC remediation for `path`,
/// `None` when the path is not TCC-guarded.
pub fn macos_tcc_fix_for_path(path: &Path) -> Option<DoctorFix> {
    if !is_macos_tcc_protected_path(path) {
        return None;
    }
    Some(DoctorFix {
        primitive: "macOS TCC (Full Disk Access)",
        issue: format!(
            "working directory '{}' sits under a TCC-guarded folder (~/Documents, ~/Desktop, ~/Downloads); Seatbelt/libsandbox may return EPERM without Full Disk Access",
            path.display()
        ),
        commands: vec![
            "# Grant Full Disk Access to your terminal in System Settings > Privacy & Security > Full Disk Access, or move the workload:".into(),
            "mkdir -p ~/projects && cd ~/projects".into(),
        ],
        explanation: MACOS_TCC_GUIDANCE.into(),
    })
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DoctorFix {
    pub primitive: &'static str,
    pub issue: String,
    pub commands: Vec<String>,
    pub explanation: String,
}

#[cfg(target_os = "linux")]
pub fn collect_linux_fixes(p: &crate::sandbox::linux::Probe) -> Vec<DoctorFix> {
    let mut fixes = Vec::new();

    if p.landlock_abi.is_none() {
        fixes.push(DoctorFix {
            primitive: "Landlock LSM",
            issue: "Landlock is unavailable (requires Linux kernel >= 5.13 with Landlock enabled)".into(),
            commands: vec![
                "# Update kernel to >= 5.13 and add landlock to LSM boot parameters in /etc/default/grub:".into(),
                "GRUB_CMDLINE_LINUX=\"lsm=landlock,lockdown,yama,apparmor,bpf\"".into(),
                "sudo update-grub && sudo reboot".into(),
            ],
            explanation: "Landlock is the primary in-process filesystem isolation layer on Linux.".into(),
        });
    }

    if !p.userns_available {
        fixes.push(DoctorFix {
            primitive: "Unprivileged User Namespaces",
            issue: "Unprivileged user namespaces (CLONE_NEWUSER) are disabled or restricted".into(),
            commands: vec![
                "sudo sysctl -w kernel.unprivileged_userns_clone=1".into(),
                "sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 # (Ubuntu 24.04 / Debian 12)".into(),
                "echo \"kernel.unprivileged_userns_clone=1\" | sudo tee /etc/sysctl.d/99-vetto-userns.conf".into(),
                "sudo sysctl --system".into(),
            ],
            explanation: "User namespaces allow vetto to mount secret-masking overlays and isolate network without root permissions. Note: vetto does NOT use bubblewrap (bwrap); AppArmor bwrap restrictions do not apply.".into(),
        });
    }

    if !p.seccomp_filter_available {
        fixes.push(DoctorFix {
            primitive: "Seccomp BPF Filter",
            issue: "Seccomp BPF syscall filtering is unavailable in current kernel".into(),
            commands: vec![
                "# Recompile or install a standard kernel with CONFIG_SECCOMP=y and CONFIG_SECCOMP_FILTER=y".into(),
            ],
            explanation: "Seccomp is used for socket blocking on Tier FS-ONLY and syscall observation.".into(),
        });
    }

    if !p.audit_feed_readable {
        fixes.push(DoctorFix {
            primitive: "Kernel Audit Feed",
            issue: "Audit log feed is unreadable by current user (optional observation)".into(),
            commands: vec![
                "sudo setfacl -m u:$USER:r /var/log/audit/audit.log".into(),
                "sudo systemctl enable --now auditd".into(),
            ],
            explanation: "The audit feed provides best-effort real-time logging of blocked Landlock file access attempts.".into(),
        });
    }

    fixes
}

#[cfg(target_os = "macos")]
pub fn collect_macos_fixes(seatbelt_available: bool, sbpl_broken: bool) -> Vec<DoctorFix> {
    let mut fixes = Vec::new();

    if !seatbelt_available {
        fixes.push(DoctorFix {
            primitive: "macOS Seatbelt (sandbox-exec)",
            issue: "Seatbelt framework or /usr/bin/sandbox-exec is unavailable or restricted".into(),
            commands: vec![
                "# Ensure macOS version is 12 (Monterey) or newer:".into(),
                "sw_vers".into(),
                "# Check System Integrity Protection (SIP) status (must be enabled for default entitlements):".into(),
                "csrutil status".into(),
                "# If running inside an unprivileged virtual machine or container, ensure host virtualization entitlements are granted.".into(),
                "# For 100% kernel Landlock confinement on macOS, consider running inside OrbStack or a Linux VM:".into(),
                "orb".into(),
            ],
            explanation: "Seatbelt (via libsandbox and /usr/bin/sandbox-exec) is the core process and filesystem restriction mechanism on macOS.".into(),
        });
    }

    if sbpl_broken {
        fixes.push(DoctorFix {
            primitive: "SBPL Fragmented Read Profiles",
            issue: "Fragmented SBPL read-isolation profiles trigger libSystem/dyld aborts on this macOS build".into(),
            commands: vec![
                "# Check for macOS system updates:".into(),
                "softwareupdate -l".into(),
                "# Or run workloads requiring strict read-masking inside an isolated Linux container via OrbStack:".into(),
                "orb".into(),
            ],
            explanation: "Certain macOS builds terminate processes when complex deny rules are evaluated during dyld initialization. Vetto falls back to write-only deny and process limits on this host.".into(),
        });
    }

    fixes
}

#[cfg(target_os = "windows")]
pub fn collect_windows_fixes(
    caps: &crate::sandbox::windows::WindowsCapabilities,
    opt: &crate::sandbox::windows::OptionalBackendReport,
) -> Vec<DoctorFix> {
    let mut fixes = Vec::new();

    if !caps.job_object_kill_on_close {
        fixes.push(DoctorFix {
            primitive: "Windows Job Objects",
            issue: "Unable to create or assign Job Objects with kill-on-close policy".into(),
            commands: vec![
                "# Verify Windows build is Windows 10 (Build 19041+) or Windows 11:".into(),
                "cmd /c ver".into(),
                "# Ensure the process is not running inside an outer restrictive Job Object prohibiting nested jobs.".into(),
            ],
            explanation: "Job Objects provide process tree containment and guaranteed cleanup of child processes on Windows.".into(),
        });
    }

    if !caps.restricted_token || !caps.low_integrity_token {
        fixes.push(DoctorFix {
            primitive: "Restricted & Low-Integrity Tokens",
            issue: "Failed to create restricted or low-integrity security tokens".into(),
            commands: vec![
                "# Check Local Security Policy / Group Policy (gpedit.msc) for token restriction policies:".into(),
                "gpresult /Scope Computer /v".into(),
                "# Ensure current user account has standard token manipulation rights and UAC is enabled:".into(),
                "powershell -NoProfile -Command \"Get-ItemProperty HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Policies\\System -Name EnableLUA\"".into(),
            ],
            explanation: "Restricted tokens strip dangerous privileges and lower integrity level to prevent privilege escalation.".into(),
        });
    }

    if !caps.appcontainer_api || !caps.experimental_create_process_in_sandbox {
        fixes.push(DoctorFix {
            primitive: "AppContainer Process Sandbox",
            issue: "AppContainer capability APIs or processmodel.dll sandbox exports are unavailable".into(),
            commands: vec![
                "# Update to Windows 11 (Build 22000+) or install Windows SDK:".into(),
                "cmd /c ver".into(),
                "# Alternatively, run vetto inside WSL2 for full Landlock/seccomp kernel confinement:".into(),
                "wsl --install".into(),
            ],
            explanation: "AppContainer process sandbox provides filesystem ACL isolation and network isolation on Windows.".into(),
        });
    }

    if !opt.windows_sandbox.feature_enabled || !opt.windows_sandbox.virtualization_firmware_enabled
    {
        let mut cmds = Vec::new();
        if !opt.windows_sandbox.feature_enabled {
            cmds.push("powershell -NoProfile -Command \"Enable-WindowsOptionalFeature -Online -FeatureName Containers-DisposableClientVM -All\"".into());
            cmds.push("powershell -NoProfile -Command \"Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V -All\"".into());
        }
        if !opt.windows_sandbox.virtualization_firmware_enabled {
            cmds.push("# Enable Hardware Virtualization (Intel VT-x or AMD SVM) in motherboard BIOS/UEFI firmware settings.".into());
            cmds.push(
                "powershell -NoProfile -Command \"Get-ComputerInfo -Property HyperVisorPresent\""
                    .into(),
            );
        }
        fixes.push(DoctorFix {
            primitive: "Windows Sandbox & Virtualization",
            issue: "Windows Sandbox optional feature is disabled or hardware virtualization is turned off in BIOS/UEFI".into(),
            commands: cmds,
            explanation: "Windows Sandbox (Hyper-V micro-VM) enables throwaway disposable VM isolation on Windows.".into(),
        });
    }

    fixes
}

#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
pub fn collect_generic_fixes() -> Vec<DoctorFix> {
    Vec::new()
}

pub fn print_fixes(fixes: &[DoctorFix]) {
    if fixes.is_empty() {
        println!("doctor --fix: all core sandbox primitives are available! No remediation needed.");
        return;
    }

    println!("doctor remediation steps:");
    for (idx, fix) in fixes.iter().enumerate() {
        println!("\n{}. [Missing: {}]", idx + 1, fix.primitive);
        println!("   Problem:     {}", fix.issue);
        println!("   Explanation: {}", fix.explanation);
        println!("   Fix command(s):");
        for cmd in &fix.commands {
            println!("     {cmd}");
        }
    }
}

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

    #[test]
    fn print_fixes_handles_empty_and_populated_lists() {
        print_fixes(&[]);

        let fixes = vec![DoctorFix {
            primitive: "User Namespaces",
            issue: "disabled".into(),
            commands: vec!["sudo sysctl -w kernel.unprivileged_userns_clone=1".into()],
            explanation: "needed for overlay masking".into(),
        }];
        print_fixes(&fixes);
    }

    #[test]
    fn macos_tcc_guided_paths_are_detected_without_filesystem_access() {
        let home = std::env::var_os("HOME")
            .map(PathBuf::from)
            .expect("HOME is set");
        for guarded in ["Documents", "Desktop", "Downloads"] {
            assert!(
                is_macos_tcc_protected_path(&home.join(guarded).join("work")),
                "{guarded} under $HOME must be TCC-guarded"
            );
        }
        assert!(!is_macos_tcc_protected_path(
            &home.join("projects").join("work")
        ));
        assert!(!is_macos_tcc_protected_path(Path::new(
            "/definitely/not/a/home/Documents/work"
        )));
    }

    #[test]
    fn macos_tcc_hint_fires_only_on_eperm_for_guarded_paths() {
        let home = std::env::var_os("HOME")
            .map(PathBuf::from)
            .expect("HOME is set");
        let guarded = home.join("Documents").join("work");
        let eperm = std::io::Error::from_raw_os_error(1);
        assert_eq!(
            macos_tcc_hint_for_error(&guarded, &eperm),
            Some(MACOS_TCC_GUIDANCE)
        );
        let not_found = std::io::Error::from_raw_os_error(2);
        assert_eq!(macos_tcc_hint_for_error(&guarded, &not_found), None);
        let plain = home.join("projects").join("work");
        assert_eq!(macos_tcc_hint_for_error(&plain, &eperm), None);

        let fix = macos_tcc_fix_for_path(&guarded).expect("fix for guarded path");
        assert_eq!(fix.primitive, "macOS TCC (Full Disk Access)");
        assert!(fix.explanation.contains("Full Disk Access"));
        assert!(macos_tcc_fix_for_path(&plain).is_none());
    }
}